[#962] Flag an admin action when a bounded storage write gives up on a configuration change - #994
Conversation
Code reviewFound 1 issue:
The conclusion the removal rests on is unaffected — the wrapper's body in the pre-PR code really is two field assignments with |
vharseko
left a comment
There was a problem hiding this comment.
Requesting changes on the strength of the finding I left above.
The commit message and the PR description name the wrong commits for the emptying of the storage.write wrapper in EntryContainer.applyConfigurationChange. 20fdcbef0d still has real transactional work inside that wrapper and 7690cb21d0 predates it; the commit that left only id2entry.setDataConfig(...) and EntryContainer.this.config = cfg behind is 9f0904fda8 (2015-04-24, OPENDJ-1725).
The change itself stands — the wrapper's pre-PR body really is two field assignments with txn unused, and the in-code comment says so without naming commits. What needs correcting is the history that goes into the permanent record: the commit message and the PR description.
|
@maximthomas this branch is reported as conflicting since today: #997 ( The textual conflict is one hunk, at the tail of the third write. On master that write is opened only when there is something to untrust — The other two hunks of this branch in that file — the three-field block taken out from between the first and the second write, and One test loses its premise.
Not a conflict yet, but next in line. #998 (approved, waiting) drops the leftover trees an index added again would adopt, and its description records the interlock with this PR: The round-1 note on the commit history of the |
3e3f94a to
ef419a0
Compare
…e write gives up on a configuration change Storage.write() no longer retries until it succeeds: JDBCStorage.write() has long been bounded by a number of attempts and a time window, and PDBStorage.write() is bounded too since OpenIdentityPlatform#937. Once the bound is spent, write() throws instead of replaying. Four configuration change paths of the pluggable backends change in-memory state around such a write, and none of them told the operator when the memory and the storage stopped agreeing: each set the server error result code and handed back a bare single line stack trace, with no message id to search the error log for and nothing said about what to do next. - AttributeIndexCfgManager.applyConfigurationDelete takes the index out of attrIndexMap and attrCryptoMap before the write, so a write which gives up leaves the trees in the storage while the configuration entry naming them is already gone. - VLVIndexCfgManager.applyConfigurationDelete does the same for a VLV index, whose data is held by two trees, the index and the counter which goes with it. - AttributeIndex.applyConfigurationChange applies the change with three writes: giving up on the second leaves the trees of the removed indexes behind, and giving up on the third leaves the entry limit the new configuration declares unapplied to the indexes which stay. - EntryContainer.applyConfigurationChange hands the entries and every index of the backend the parameters to encode with from now on, and giving up between the two leaves them encoded under settings which no longer agree. All four now report a message of their own - ERR_CONFIG_INDEX_DELETE_FAILED, ERR_CONFIG_VLV_INDEX_DELETE_FAILED, ERR_CONFIG_INDEX_CHANGE_FAILED and ERR_CONFIG_BACKEND_DATA_CHANGE_FAILED - which names what diverged and what has to be done about it, keeps the stack trace as its trailing cause, sets adminActionRequired, and is logged as well as returned: the divergence outlives the session which asked for the change. The order of the two deletions is deliberately left alone: deleting inside the write would let a replayed attempt find nothing to delete and commit an empty transaction, reporting success for work it did not do. AttributeIndex.applyConfigurationChange now publishes each part of the change after the write which applied that part, rather than all of it between the first write and the second: the map of indexes and the indexing options once the deletion has committed, still inside the entry container lock, and the configuration once the update has committed. EntryContainer.applyConfigurationChange no longer opens a transaction. Its body has performed no transactional work since what it wrapped was removed - 7713117 added the wrapper around the opening of the subordinate indexes and their entry limits, and 9f0904f (OPENDJ-1725) took that work out of it, leaving id2entry.setDataConfig and the assignment of config behind - so what was left was a transaction a bounded storage could give up on, in the middle of a sequence no rollback can undo. The single step which can fail, newDataConfig(cfg), now runs before the first mutation. The entry container lock stays: publishing to the other threads was always its doing and never the transaction's. ConfigChangeGivesUpTest covers the four paths through a backend whose storage gives up on a chosen write, and pins what the messages claim, including the adoption of the trees left behind that ERR_CONFIG_INDEX_DELETE_FAILED describes (OpenIdentityPlatform#990).
ef419a0 to
f39fffa
Compare
|
Rebased onto master ( The conflict. One hunk, the tail of the third write, resolved as you describe: master's structure is kept — the write is opened only when The test which lost its premise. The history of the wrapper. Confirmed at the SHAs: the parent of Runs on #998. Understood: whichever lands second inverts |
vharseko
left a comment
There was a problem hiding this comment.
Approving on f39fffa615. The round-1 request is met, the rebase is what the notes asked for, and what the change claims about itself holds at the SHAs and in the runs.
What changed since 3e3f94ac69. The range-diff of 2a7bb9d7ed..3e3f94ac69 against 1af0a1247d..f39fffa615 shows four deltas and nothing else: the commit message (history paragraph, the "write 3" bullet), the tail hunk of AttributeIndex.applyConfigurationChange (config = newConfiguration after the setIndexEntryLimit loop, comment rewritten), the copyright hunk of AttributeIndex.java gone because master carries that line since #997, and the test (5000, javadoc, comment). EntryContainer, PDBStorage, backend.properties and the other nine test cases are byte-for-byte round 1. merge-tree onto the current master d0082246b9 is clean; 624–627 are still free there (last ordinal 623, #1037 touched no properties).
The history, at the SHAs. 77131174b39^ (736f87ab0f) has the same body without a storage.write around it, so 77131174b39 did add the wrapper; 9f0904fda8^ (d12a783ffe) still has openSubordinateIndexes(txn, cfg) and the two setIndexEntryLimit calls inside it; 9f0904fda8 leaves id2entry.setDataConfig and EntryContainer.this.config = cfg. Commit message and description now name those two, with the right dates.
The new position of config. Between the old position and the new one there is the untrust write and the setIndexEntryLimit loop; the readers of config are open/close (listener registration), getAttributeType, isIndexed, isConfidentialityEnabled, getName and evaluateExtensibleFilter, none of them on that path — and the attribute type is the same in both configurations anyway, so the getAttributeType() in the catch reads nothing that differs.
The test premise. planIndexUpdates puts an index whose limit is raised into indexesToUntrust, so 4000 → 5000 opens the third write. Measured here as well: on f39fffa615, ConfigChangeGivesUpTest 10/10 and ReplayedConfigChangeTest 20/20 (reactor build up to opendj-server-legacy, JDK 26); with 5000 → 100 exactly the one case fails, at :236, Expecting actual: Success not to be equal to: Success, the other nine green. CI on the head: every build-maven leg green except ubuntu-latest, 17, still queued when I looked.
#998 and #1000 are both still open, so the interlock recorded above stands as written.
…m#994 what an index created again does now, and pin it where OpenIdentityPlatform#994 pinned the adoption OpenIdentityPlatform#994 landed first and described the behaviour this change removes: ERR 624, 625 and 626 told the operator that an index, a VLV index or an index type declared again adopts the trees a failed deletion left behind and is trusted over their stale content, and ConfigChangeGivesUpTest.anIndexCreatedAgainAdoptsTheTreesAFailedDeletionLeftBehind pinned exactly that so that the fix would turn it red rather than leave the sentence quietly untrue. The three sentences now say what happens on each side of the line this change draws: an index created again while the backend is open discards those trees and starts empty, asking to be rebuilt, while one created while the backend is disabled still adopts them at the next open and is trusted over their content, which is the follow-up the description names. The pin is inverted into anIndexCreatedAgainDiscardsTheTreesAFailedDeletionLeftBehind - untrusted, admin action, NOTE 535 and WARN 628 in the change result - and anIndexChangeWhichGivesUpUpdatingDoesNotPublishWhatItCouldNotApply arms the fourth write rather than the third, since a change which adds an index type now opens the write which drops its leftovers before the three it always made.
Summary
Storage.write()is bounded on both engines —JDBCStorage.write()has been for a while, andPDBStorage.write()since #937 — so once the bound is spent,
write()throws instead of replaying. Its javadoc already names theobligation that creates for its callers:
Four configuration change paths of the pluggable backends are exactly those callers, and none of them met the
obligation: each set the server error result code and handed back a bare single line stack trace. This PR makes
each of them name what diverged and ask for the administrative action which repairs it, and moves two of them so
that less diverges in the first place.
What each path leaves behind when the bound is spent
EntryContainer.AttributeIndexCfgManager.applyConfigurationDeleteEntryContainer.VLVIndexCfgManager.applyConfigurationDeletevlv.<name>and its counterAttributeIndex.applyConfigurationChangeEntryContainer.applyConfigurationChangeEvery one of them reported it like this:
No message id, nothing said about what diverged, and no
adminActionRequired.What the operator gets now
Four message ids (624-627 in
backend.properties):ERR_CONFIG_INDEX_DELETE_FAILED,ERR_CONFIG_VLV_INDEX_DELETE_FAILED,ERR_CONFIG_INDEX_CHANGE_FAILED,ERR_CONFIG_BACKEND_DATA_CHANGE_FAILED.Each names the index or the base DN, keeps the stack trace as its trailing cause, and states the repair. Each site
also sets
adminActionRequiredand logs the message.Three things about how this reaches the operator, since they shaped the change:
adminActionRequiredis not what the operator reads on a failed change.dsconfigprints the messages ofthe result; the flag is what the configuration framework and programmatic callers see. So the instruction has to
be in the message text itself, and it is.
logger.errorcall is not redundant with the framework.ConfigurationHandler.handleConfigChangeResultdoes log a non-SUCCESS result, but as an argument of
ERR_CONFIG_CHANGE_RESULT_ERROR, andTextErrorLogPublisherwritesmsgID=message.ordinal()of the outer message. Only the call at the site putsthese ids into the error log, which is what makes the divergence findable after the session that caused it has
ended — and it outlives that session by definition.
stackTraceToSingleLineStringwalksgetCause()only and never prints suppressed exceptions. That is whyPDBStorage's give-up exception carries "attempt cap" / "retry window" in its own message rather than as asuppressed one: that message is how the operator learns which of the two bounds was spent, and therefore whether
raising the attempts or the window would have helped. The comment there is corrected to say so.
What is deliberately not changed
The order of the two deletions. The index is taken out of the map before the write, and the comment already in
EntryContainerexplains why: deleting inside the write would let a replayed attempt find nothing to delete andcommit an empty transaction, reporting success for work it did not do. The fix is to report the divergence, not to
trade it for a false success.
No storage-status or
id2entry != nullprecondition onEntryContainer.applyConfigurationChange. Noneighbouring
applyConfigurationChangehas one (PDBStorage,JEStorage,JDBCStorage,RootContainer;BackendImplguards onlyrootContainer != null), and the one reachable way to call it on a half-open containeris a listener leak — filed separately as #993.
AttributeIndex.applyConfigurationChangepublishes each part after the write which applied itThe three fields used to be swapped in one block between the first write and the second. They now go with the
write that earns them: the map of indexes and the indexing options once the deletion has committed, still inside
the entry container lock, and the configuration last, once the third write has committed and the entry limit has
been applied to the index types which stay. Since #997 that write only removes the TRUSTED flag of the index types
whose limit was raised, and is not opened at all when none was; the limit itself follows it, in memory.
The lock has drained every operation which enters through shared access, so the window in which the map names
trees the write has just deleted lies inside it and no search sees it. Publishing after
write()rather than frominside the
WriteOperationalso means an operation the storage replays publishes once, from the attempt whichcommitted.
EntryContainer.applyConfigurationChange: the transaction was a vestige77131174b39(2014-12-16) introduced the wrapper around transactional work — the opening of the subordinateindexes and their entry limits;
9f0904fda8(2015-04-24, OPENDJ-1725) took that work out of it whenid2childrenand
id2subtreegave way toid2childrenCount, and the wrapper stayed. What remained inside it was two fieldassignments —
ID2Entry.setDataConfigandEntryContainer.this.config = cfg— withtxnunused.It is not a storage-availability probe either: on PDB in READ_ONLY,
newStorageImpl()returnsReadOnlyStorageImpl(:768), whosewriteisoperation.run(this)with no transaction, and afterclose()ReadOnlyEmptyStorageImpl(:870) does the same;TracedStorage.writeadds nothing of its own for an operationwhich never touches the transaction. No test referenced it.
So the write is removed, and the one step which can fail —
newDataConfig(cfg)— now runs before the firstmutation, which makes the invariant structural rather than a matter of reading order.
The narrowing this causes, stated plainly: on JE, a call made while the storage is already closed used to
throw
NullPointerException(JEStorage.beginTransactionreadsenvConfig, whichclose()sets to null) and bereported as a failed
dsconfig; it now applies the in-memory settings and returns SUCCESS. On a JE storage thatis open but read-only nothing changes:
beginTransaction()already returned null, because that environment isconfigured
setTransactional(false). Read-only entry containers really do exist alongside the live ones —BackendImpl.getReadOnlyRootContainer(:1204), used byexportLDIF(:625),verifyBackend(:732) andBackendStat(:917) — and they register the same configuration listeners in their constructor.The catch on that path is now close to unreachable:
CryptoSuite.newParametersassigns avolatilefield andsetDataConfigassigns a field. The message and the flag there are defensive, and the test reaches them by makingnewDataConfigfail.Tests
New
ConfigChangeGivesUpTest(10 tests, groupsprecommitandpluggablebackend).PDBBackendis final, so thetest subclasses
BackendImpl<PDBBackendCfg>and returns a decorator fromconfigureStorage. The decorator runsthe operation inside the transaction the delegate opens and raises a checked exception from within it, so the
transaction is rolled back and the caller is handed the failure — which is exactly what the last attempt of a
bounded retry loop leaves behind. That models a spent bound without depending on the bound itself.
What is pinned:
message id, names the index or the base DN, sets
adminActionRequired, and the trees are still instorage.listTrees();on the third write does not publish the configuration it could not apply — armed with a raised entry limit,
which untrusts the index types which stay and so opens that write, where a lowered one does not since [#991] Decide and report an index configuration change outside the write which is replayed #997;
the map, the indexing options and the configuration;
ERR_CONFIG_INDEX_DELETE_FAILEDclaims that an index created again adopts the leftover trees and is trustedover their stale content: that claim is pinned, so it cannot become untrue in silence when Adding a backend index adopts the trees left behind by a previous index of the same attribute, keeps their TRUSTED flag, and reports nothing #990 is fixed.
Mutation checks were run by hand against the working tree: dropping
setAdminActionRequired(true)at each site,restoring the old position of the three-field swap, and restoring the removed
storage.writeeach fail a test.(rebased onto master at
1af0a1247d;ReplayedConfigChangeTestnow carries the 20 cases #997 added, which pin thecount and the order of the three writes on the same road)
Adjacent defects found while doing this, filed separately
flag and reports nothing. The two delete messages describe this behaviour, and the test above pins it.
createIndex/updateIndexreport into theConfigChangeResultfrom inside theWriteOperation,so a replay repeats the messages, and a replayed entry limit change can leave a stale index flagged TRUSTED.
Fixed by [#991] Decide and report an index configuration change outside the write which is replayed #997, merged 2026-09-19; this PR is rebased over it.
DefaultIndex.setConfidentialonly compares, soconfidentiality-enabledtakes effect on no indexuntil the backend is restarted, and untrusts the index again on every later change.
ConfigExceptionfromEntryContainer.openleaves five configuration listeners registered on ahalf-open container. Fixed by [#993] Register an entry container's configuration listeners only once it has opened #999, merged 2026-09-19.
Closes #962.