[#873] Give each backend its own compressed schema trees - #881
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
Nice change — the per-backend prefix is the right shape for #873, the migration is idempotent and crash-safe, and I checked that tokens never reach the replication protocol, the changelog, LDIF export or a backup archive, so this cannot break cross-server decoding. Two things I'd like fixed before merge, plus some smaller notes.
Cassandra treeExists() fails open where the old code failed closed (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java:262
}catch (RuntimeException e) {
for (Throwable cause=e; cause!=null; cause=cause.getCause()) {
if (cause instanceof InvalidQueryException) {
return false;
}
}
throw e;
}InvalidQueryException is the driver's bucket for the whole INVALID protocol code, not a "table absent" signal — keyspace absent, undefined column and unconfigured-table all arrive as the same type. Returning false for any of them means a populated tree is reported absent, and load() then skips it:
// PersistentCompressedSchema.java:289 / :315
if (txn.treeExists(ocTree)) { ... }with CompressedSchema.getAttributeId allocating from id = mappings.adDecodeMap.size(), i.e. restarting at 0 and overwriting live definitions. That is the same silent mis-decoding this PR exists to fix. The two guards are independent, so one bad probe is enough to corrupt one token space on its own.
Before this PR loadTrees opened the cursor unconditionally, so a rejected query propagated out of RootContainer.open and the backend failed to open — fail-closed. Cassandra is now the only implementation of the new SPI method that fails open; JE, PDB and JDBC all throw StorageRuntimeException.
The realistic trigger is a coordinator whose schema does not yet contain the table. The driver waits for schema agreement only on SchemaChange responses, its checker completes with false (not an error) on the 10s timeout, CASStorage never calls isSchemaInAgreement(), and a QueryValidationException is never retried on another node. The driver's own reference.conf documents exactly this ("getting an unconfigured table error for a table that you created right before"), and notes agreement never succeeds in a mixed-version cluster — i.e. during a rolling upgrade.
Suggested fix — ask the driver's local metadata snapshot instead of inferring absence from an exception, and let everything else propagate:
if (!session.getMetadata().getKeyspace(keyspace)
.flatMap(ks -> ks.getTable(table)).isPresent()) {
return false; // the backend's table was never created
}
return execute(...).one() != null; // any rejection now fails the openThat mirrors what JDBCStorage.isExistsTable already does with con.getMetaData().getTables(...). If you prefer to keep the exception shape, at minimum drop the cause-chain walk and catch InvalidQueryException directly.
toTableName() does not cover the paths it was written for (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:278
The new comment promises that probing a tree will not enrol it:
toTableName()rather thangetTableName(): asking whether a tree is there must not enrol it in tree2table.
but only isExistsTable was switched. The caller re-enrols the tree on the very next statement, because both of these still use the populating getTableName():
// :261 needsLegacyDefinitions -> recordCount -> getRecordCount
"select count(*) from "+getTableName(treeName)
// :516 copyMissingRecords / loadTrees -> CursorImpl
this.tableName=getTableName(treeName);listTrees() returns tree2table.asMap().keySet() and removeStorageFiles() drops every table it names, so the shared legacy tables still end up on the drop list of any backend that migrates — on every open, forever, since the legacy pair is deliberately never deleted.
To be clear on severity: this is not a regression. On master isExistsTable also did tree2table.get(treeName), and the legacy names were the backend's own names, so master enrolled and dropped them too. The defect is that the guard is incomplete and its comment and new test (testProbingATreeDoesNotPutItUpForRemoval, which only exercises treeExists) claim a protection that isn't delivered.
Minimal fix: keep the populating getTableName() on paths that create or write a tree (openTree, clearTree, deleteTree, put/delete), and use the pure toTableName() on read-only paths (read :243, getRecordCount :261, CursorImpl :516). Every tree the backend owns passes through openTree(name, true) during startup, so listTrees() still sees the complete owned set.
Nits
- Read-only open creates the new trees on JE:
load()callstxn.openTree(adTreeName, shouldCreate)even when read-only, andJEStorage.openTreeignorescreateOnDemand— it reachesenv.openDatabase(null, name, dbConfig())withsetAllowCreate(true). Since the names changed, an offlineexport-ldif/verify-indexon an unmigrated backend now leaves two empty databases behind. Harmless, butaReadOnlyOpenReadsTheSharedTreesWhereTheyLieasserts this against aDummyWriteableTransaction, so it passes while the real engine does something else. - A brand-new backend inherits the legacy snapshot:
needsLegacyDefinitions()is true for any backend whose own trees are smaller than the legacy pair, including one created fresh after the upgrade on a shared JDBC/Cassandra database. It has no entries at all, yet copies every foreign definition and starts its token space at N. Gating on the own trees being absent would avoid it. isExistsTableprobes every catalog and schema:con.getMetaData().getTables(null, null, null, {"TABLE"})— null catalog and null schema. On MySQL that spans every database on the server.treeExistscan then answertruefor a table that the unqualifiedselect count(*) from opendj_<hash>ingetRecordCountcannot resolve, throwing out ofneedsLegacyDefinitions, which sits outside the migration's try/catch. Pre-existing, but this PR makes a new decision depend on it.- The cause-chain walk is dead code: the comment says "the statement cache may hand back the loader's failure wrapped", but Caffeine rethrows a loader
RuntimeExceptionunwrapped (catch (RuntimeException e) { throw e; }) and only wraps checked exceptions — whichsession.preparecannot throw. The walk only widens what gets swallowed. treeExistscontradicts the SPI javadoc it ships with:ReadableTransaction.java:56says "This is not the same question as whether the tree is empty";CASStorage.java:253redefines it as "exists here means holds at least one record". Harmless for today's only caller, a trap for the next.- No Cassandra test for the new
treeExists: JDBC got three new tests; the CassandraTestCasegained none, so neither the fail-open branch nor the empty-tree semantics is exercised. - CodeQL comment on
JDBCStorage.java:130: false positive re-triggered by the code move — SHA-224 is unchanged from master and only derives a deterministic table name, no security property. Worth dismissing explicitly rather than leaving open. - Downgrade defeats the count heuristic:
needsLegacyDefinitionscompares record counts only. After a downgrade the old binary resumes allocating in the legacy trees, and on re-upgradelegacyCount == ownCountmakes migration a no-op while the two disagree. Downgrade isn't a supported path, but the javadoc's "trees only ever grow" invariant holds only one-way and could say so.
|
Thanks — the fail-open was a real hole, and the incomplete guard was worse than the comment it shipped with. Both are fixed in 9472182, together with three of the nits; the PR description is updated to match. Where I went a different way than suggested, the reasoning is below. Cassandra
|
| suite | result |
|---|---|
PersistentCompressedSchemaTest |
5/5 |
PgSqlTestCase |
40/40 |
cassandra/TestCase |
39/39 |
JETestCase |
34/34 |
PDBTestCase |
34/34 |
PDBStorageTest |
3/3 |
…a trees The two trees holding the compressed schema definitions were named from a literal, so they carried no backend qualifier while every other tree of a backend carries its base DN. That is harmless where a storage holds one backend - JE and PDB give each its own directory, and the Cassandra backend names its table after the backend id - but the JDBC backend derives its table name from the tree name alone, so two JDBC backends addressing one database mapped to one pair of tables. Each allocated tokens from the size of its own in-memory map under its own lock, so both handed out the same token for different attribute descriptions and overwrote each other's definitions with a blind put. Nothing showed until a restart, after which the entries of the losing backend decoded as the wrong attributes, silently. The trees are now named "/compressed_schema_<backendId>/...". A backend upgraded from a version that shared them migrates on its first open: load() copies the records the shared trees hold and its own do not, and fails the open if that copy cannot complete - carrying on with no definitions at all would restart token allocation from zero and mis-decode every entry already written. The shared trees are read, never emptied: on a shared database they may still be the only copy another backend has, and leaving them is what makes a downgrade possible. Only keys the backend does not already hold are copied, so the migration is safe to re-run after it was interrupted, which is how a storage without transactions recovers from a copy that stopped halfway. A read-only open - export-ldif, verify-index - cannot migrate, so it reads the shared trees where they lie. Deciding that needs a question no read can answer, since a storage may materialize a tree on first access (JE opens its databases with setAllowCreate) or reject the access outright (JDBC, when no such table exists), so ReadableTransaction gains treeExists(). The JDBC implementation derives the table name without entering it into tree2table, which removeStorageFiles() drops: asking about another backend's tree must not put it up for removal. UpgradeTasks is left alone. Its rename step migrates 2.x local-db backends to pluggable JE by renaming JE databases through a JE Environment directly and never runs for PDB or JDBC; what it produces is the shared pair, which the first open then migrates like any other.
… no write can follow The Cassandra treeExists() took every InvalidQueryException for an absent tree, and the driver reports the whole INVALID protocol code that way - an absent keyspace, an unknown column, a table a coordinator has not caught up with during a rolling upgrade. Calling a populated tree absent has the compressed schema load nothing, restart its token allocation from zero and overwrite the definitions the entries already written were encoded with, which is the corruption this change exists to prevent. Only a read-only transaction, where no write can follow the answer, may still read a rejected query as an absent table; a writeable one has just created it through openTree(), so a rejection there fails the open as it did before. read(), getRecordCount() and the cursor of the JDBC backend now take the pure toTableName() that the existence probe already used, so reading a tree this backend does not own no longer enrols it in the map removeStorageFiles() drops: the guard covered treeExists() alone, and its test exercised only that while the migration counts the legacy tree and copies it out. The compressed schema no longer asks openTree() of a read-only open, which JEStorage grants regardless of createOnDemand, leaving two empty databases behind an offline export-ldif or verify-index of an unmigrated backend.
|
Please resolve conflicts |
9472182 to
f82de94
Compare
|
Rebased onto master (0b9c0f6), which had moved on under the JDBC backend since this branch was cut (#886 catalog lookup, #866 table stamping, #867 SQL Server upsert). Conflicts and how they were resolved:
|
fixed |
maximthomas
left a comment
There was a problem hiding this comment.
The separation itself looks right, and the round-1 fixes hold up: toTableName() is now used on every JDBC read path, the read-only branch no longer calls openTree(), and the Cassandra narrowing in f82de940 does what it says. One blocker and three issues below, then nits.
Migration catch-all defeats the PersistIt retry (blocker)
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java:255-262
catch (final Exception e)
{
throw new InitializationException(ERR_COMPSCHEMA_CANNOT_MIGRATE.get(...), e);
}The migration runs inside RootContainer.open()'s WriteOperation, i.e. inside the storage retry loop. PersistIt matches by type on what leaves operation.run(this) (opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:653):
catch (final RollbackException e)
{
// retry after random sleepA RollbackException from copyMissingRecords arrives there wrapped, misses that clause, falls into catch (final Exception e) { txn.rollback(); throw e; }, and the backend open fails permanently — where the same conflict would previously have been replayed. Please rethrow the conflict types before the catch-all.
Only PersistIt is affected: JDBC's isRetryableConflict walks the cause chain (MAX_CAUSE_HOPS = 16) so #867's retry still fires through the wrapper, and JEStorage.write() has no retry loop.
Every JDBC read recomputes a SHA-224 table name (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:190, called from :1038 read(), :1056 getRecordCount(), :1073 isExistsTable(), :1330 CursorImpl ctor.
static String toTableName(TreeName treeName) {
final MessageDigest md = MessageDigest.getInstance("SHA-224");
final byte[] messageDigest = md.digest(treeName.toString().getBytes());
...The split from getTableName() is correct — listTrees() is tree2table.asMap().keySet(), so a probe must not enrol — but toTableName() has no memo, and txn.read() is the per-entry hot path (DN2ID:127, ID2Entry:485, DefaultIndex:274, VLVIndex:778, State:96). A 1000-entry search now does 1000 JCA provider lookups plus digests where it previously did 1000 map gets. A second, non-enrolling cache keyed by TreeName keeps the split and removes the regression.
Cassandra treeExists() treats any InvalidQueryException as "absent" (major)
opendj-server-legacy/src/main/java/org/opends/server/backends/cassandra/CASStorage.java:260-279
}catch (InvalidQueryException e) {
if (accessMode.isWriteable()) { throw e; }
return false;InvalidQueryException is the driver's whole INVALID bucket, not "no such table". A backend table that exists with a different shape (no key column) makes every treeExists() answer false, so needsLegacyDefinitions() sees zero counts, loadTrees() loads nothing, and the server starts with an empty token table and decodes existing entries as the wrong attributes — the #873 failure mode from the other direction.
The accessMode guard does not limit this as intended, because CASStorage.read() builds its transaction as new TransactionImpl(AccessMode.READ_ONLY) unconditionally: under read() the rethrow is unreachable however the storage was opened. Suggest matching "unconfigured table"/"does not exist" (or checking keyspace metadata) and failing loudly on everything else.
Three guard tests cannot fail (major)
opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PersistentCompressedSchemaTest.java
Never-overwrite is untested. makeStoreLookPreUpgrade() seeds the legacy trees from the backend's own trees, so both sides always hold identical bytes:
private void makeStoreLookPreUpgrade(String backendId) throws Exception
{
copyTree(ownTree(backendId, AD), LEGACY_AD);Delete the if (txn.read(to, key) == null) guard in copyMissingRecords and all five tests stay green. Seeding one legacy token with a different value and asserting the own value survives would close it.
The read-only test cannot fail for the bug it guards. aReadOnlyOpenReadsTheSharedTreesWhereTheyLie asserts treeExists(ownTree("backendA", AD)).isFalse(), but the fixture in opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/DefaultIndexTest.java:259 does nothing when createOnDemand is false:
public void openTree(TreeName name, boolean createOnDemand)
{
if (createOnDemand)
{
storage.putIfAbsent(name, new TreeMap<ByteString, ByteString>());
}
}The JE defect being guarded is precisely that JEStorage creates the database for false. Re-introduce txn.openTree(adTreeName, shouldCreate) in the read-only branch and the suite stays green while a real offline export-ldif again leaves two empty databases behind.
Fail-closed is untested. ERR_COMPSCHEMA_CANNOT_MIGRATE appears nowhere under src/test. DummyWriteableTransaction makes a throwing put easy to inject; fine as a follow-up.
The migration NOTE can report a migration that did not happen (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java:251-253
final long copied = copyMissingRecords(txn, LEGACY_AD_TREE_NAME, adTreeName)
+ copyMissingRecords(txn, LEGACY_OC_TREE_NAME, ocTreeName);
logger.info(NOTE_COMPSCHEMA_MIGRATED, copied, backendId, LEGACY_TREE_PREFIX, adTreeName.getBaseDN());Logged unconditionally (fires with copied == 0), before the transaction commits (a later throw in openAndRegisterEntryContainers rolls the copy back and the line still stands), and again on every PersistIt replay with a full count. It is the only evidence the migration path emits. Suggest logging only when copied > 0, after commit.
Read-only "own wins" holds only for the decode map (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java:206-212 — the comment says loading legacy first makes an already-migrated definition win for the same token. loadAttributeToMaps (opendj-server-legacy/src/main/java/org/opends/server/api/CompressedSchema.java:505) writes two maps keyed differently:
mappings.adEncodeMap.put(ad, id); // keyed by description
...
mappings.adDecodeMap.set(id, ad); // keyed by tokenThe second load overwrites decodeMap[id] but leaves the displaced legacy pair in encodeMap. A backend created after the fix allocates from 0, so ids collide by construction with legacy definitions from another backend: legacy 5 = cn then own 5 = sn gives decodeMap[5] = sn with cn -> 5 still standing. Encoding cn then yields a token that decodes to sn. Whether that is reachable depends on whether anything encodes during a read-only open — if nothing does, the comment just claims more than the code delivers.
Nits
- Compressed schema keyed by backend id:
PersistentCompressedSchema.java:129-138— every other tree is named from the base DN, so on JDBC they survive a config rebuild; these do not. Delete and re-create the backend under a new id over the same database and token allocation restarts at 0 over entries encoded with the old mapping. Narrow, sincebackend-idis read-only in config. CursorImpl.delete()bypasses enrolment:JDBCStorage.java:1330cachestoTableName(treeName)and:1417writes through it, contradicting the new javadoc rule that write paths takegetTableName(). Latent — every owned tree is also opened withopenTree(name, true). TakinggetTableName()in the ctor costs one call per cursor.- Two javadocs contradict on downgrade:
PersistentCompressedSchema.java:224says "Downgrading across the separation is not a supported path";:268-269says leaving the legacy tree "is what makes a downgrade possible". treePrefix()javadoc misstatesTreeName: it saysTreeName"splits its string form on '/' and states that no component may contain one".TreeName.valueOf()splits at the last/, so a baseDN containing one round-trips, andTreeNamestates no such rule. The escaping is fine; the reason given is not.- Messages 618/619 name prefixes, not trees:
LEGACY_TREE_PREFIXiscompressed_schemaandadTreeName.getBaseDN()iscompressed_schema_<id>— neither appears inbackendstat list-raw-dbs. The object-class tree is never named, including in the failure message where knowing which of the two failed is the point. - JDBC read-only comment describes an unreachable path:
JDBCStorage.java:1068-1071justifies movingisExistsTable()intoReadableTransactionImplwith a JDBC read-only open, butRootContainer.open()always usesstorage.write()andWriteableTransactionTransactionImpl's ctor throwsReadOnlyStorageExceptionon a non-writeable storage. The move is right — the live caller is the writeable migration probing the legacy tree — the stated reason is not. needsLegacyDefinitions()probes forever: twotreeExistsplus up to fourgetRecordCounton every open of every backend, including installs that never had legacy trees. On JDBC that is two catalog probes and up to fourselect count(*). No "migration done" marker lets it stop.
… to replay The migration wrapped everything that left copyMissingRecords() in an InitializationException. It runs inside the WriteOperation of RootContainer.open(), and PDBStorage.write() decides by type what to do with what leaves it: a RollbackException arrives there wrapped, misses the retry clause and fails the open permanently, where the same conflict used to be replayed. A StorageRuntimeException now leaves the migration with the type the storage gave it; everything else still fails the open, and ERR_COMPSCHEMA_CANNOT_MIGRATE names the pair of trees that failed rather than a prefix, as NOTE_COMPSCHEMA_MIGRATED now names both pairs. The JDBC read paths take a new readTableName(), answered from the tree2table memo where the tree is in it. Every tree the backend owns is enrolled as it is opened, so read() - the per-entry hot path - is a map lookup again rather than a JCA provider lookup and a SHA-224 digest per call, while a tree this backend does not own is still computed without being enrolled. The cursor reads through that name and deletes through the enrolling one, resolved once per cursor: a delete writes to the tree, so removeStorageFiles() has to be able to name it. The Cassandra treeExists() reads a rejected query as an absent tree only where the message names an absent table or keyspace. The type covers the whole INVALID code, so a table that exists with a shape this backend did not write would otherwise answer absent and have the compressed schema start again from zero. The migration is reported by RootContainer.open() after the transaction commits and only where something was copied. That line is the only evidence the path emits, and one standing for a copy a rollback undid, or repeated once per replay, says less than none. Three guards no test could falsify are covered now: a shared token standing for another attribute must not overwrite this backend's own definition, a migration that cannot complete fails the open, and a storage failure passes through with its own type. The read-only test can fail at last, its fixture materializing a tree whenever one is asked for, as JEStorage does. The javadoc says what TreeName states about '/', what loading the shared trees first settles in a read-only open and what it does not, why isExistsTable() sits on the readable transaction, what probing on every open costs, and what naming these two trees after the backend id costs when a backend is re-created under another id.
|
Thanks — the blocker was real, and the three untestable guards were the more useful finding of the two. Everything is fixed in d1dd75e and the PR description is updated to match. Two places where I went a different way, and one correction, are below. The migration catch-all — fixedAgreed on all of it. catch (final StorageRuntimeException e)
{
// Left with the type the storage gave it. PDBStorage.write() decides by type what to do
// with what leaves operation.run(this): a transaction conflict reaches its retry as a
// RollbackException only, so wrapping it here would turn a conflict that used to be
// replayed into a permanent failure of the open.
throw e;
}Nothing is lost on the fail-closed side: a storage failure that is not a conflict still fails the open, through While there,
|
| nit | outcome |
|---|---|
keyed by backend-id, not base DN |
Kept, and written down: treePrefix() now records that these two trees alone do not follow the entries when a JDBC backend is re-created under another id, and the "Not fixed by this" section says an id change over populated storage needs an export and a re-import — as it always has on Cassandra, where the entries do not survive it either. |
CursorImpl.delete() bypasses enrolment |
Fixed, but not in the constructor: enrolling there would put the shared tree into tree2table on the very cursor the migration reads it with, and re-break testProbingATreeDoesNotPutItUpForRemoval. The cursor reads through readTableName() and resolves the enrolling name lazily on its first delete(), so a cursor that never deletes costs nothing. New testDeletingThroughACursorPutsTheTreeUpForRemoval covers the write side. |
| two javadocs contradict on downgrade | Fixed — leaving the shared trees is for a backend that has not been upgraded, not for going back; both places say that now. |
treePrefix() javadoc misstates TreeName |
Half right, and reworded accordingly: valueOf() does split at the last /, as you say, but TreeName's class javadoc does state the rule — "Note: This class assumes name components don't contain a '/'" (TreeName.java:24). So the escape rests on a documented assumption; the javadoc now gives that as the reason instead of the round-trip claim. |
| messages 618/619 name prefixes, not trees | Fixed; both name both trees in full, and 619 names the pair that failed. |
| JDBC read-only comment describes an unreachable path | Fixed — the comment now gives the live reason: the writeable migration probing a tree it must not create or enrol. |
needsLegacyDefinitions() probes forever |
Left as it is, and documented: two existence probes per open, and the counts only where the shared trees are still there. A "migration done" marker would be this backend's own record in a database it may be sharing, which is the thing this change is trying to stop doing. |
Test runs
| suite | result |
|---|---|
PersistentCompressedSchemaTest |
8/8 |
PgSqlTestCase |
57/57 |
MySqlTestCase |
57/57 |
cassandra/TestCase |
39/39 |
JETestCase, EncryptedJETestCase |
34/34 each |
PDBTestCase, EncryptedPDBTestCase |
34/34 each |
PDBStorageTest |
3/3 |
DefaultIndexTest |
5/5 |
OnDiskMergeImporterTest |
29/29 |
Fixes #873
Problem
The two trees holding the compressed schema definitions were named from a literal, so they carried no backend qualifier while every other tree of a backend is named from
EntryContainer.getTreePrefix()and so carries its base DN:That is harmless where a storage holds a single backend — JE and PDB give each its own directory, and
CASStorage.getTableName()names its table after the backend id — butJDBCStoragederives its table name from the tree name alone, so the two names were constants for every JDBC backend of every server sharing a database:Two such backends therefore shared one token space.
CompressedSchema.exclusiveLockis an instance field, and a token is allocated from the size of the instance's own decode map, so two backends that loaded the same table at open held the same next token: backend A allocated N forcnand wrote row N →cn, backend B allocated N forsnand overwrote it.PersistentCompressedSchema.store()issues a baretxn.put, an upsert with no condition, so the last writer won silently.Both kept serving from memory, so nothing showed until a restart. After it,
decodeAttributereturns whatever the row holds and raisesERR_COMPRESSEDSCHEMA_UNRECOGNIZED_AD_TOKENonly when the token is absent altogether — a token resolving to the wrong attribute description decodes without any error at all.Nothing validates
getDBDirectory()for uniqueness, so the configuration is accepted without a word. The test suite already worked around the collision (jdbc/TestCase#dropStaleTrees).Fix
The trees are now named
/compressed_schema_<backendId>/..., the qualifier being the backend id rather than an entry container's tree prefix:PersistentCompressedSchemais built once perRootContainer, before any entry container exists, andconfig.getBaseDN()is a set, so a backend holding two base DNs has no single prefix to borrow.Migration lives in
load(), not in the upgrade tooling. Written against the SPI, it covers JE, PDB, JDBC and Cassandra with one piece of code and moves two small trees per backend. On its first open a backend upgraded from a version that shared the pair copies across the records the shared trees hold and its own do not, then loads from its own. Specifically:ERR_COMPSCHEMA_CANNOT_MIGRATE, naming the pair of trees that failed. Carrying on with no definitions would restart token allocation from zero and mis-decode every entry already written, reporting nothing. A failure the storage reports as its own is left with the type it was given: the migration runs inside theWriteOperationofRootContainer.open(), andPDBStorage.write()decides by type what to do with what leaves it, so aRollbackExceptionarriving there wrapped would fail the open permanently where the same conflict used to be replayed.export-ldifandverify-indexread the shared trees where they lie, loading them before the backend's own so that anything already migrated wins for the same token. That settles the decode map only —CompressedSchema.loadAttributeToMapskeys the encode map by attribute description, so a displaced legacy description stays in it — which is harmless because nothing encodes during a read-only open, and the comment now says that rather than claiming more. It asksopenTree()of nothing either:JEStorage.openTreeignorescreateOnDemandand reachesenv.openDatabase()withsetAllowCreate(true), so asking at all would leave two empty databases behind an offline tool run against a backend that has not migrated yet.RootContainer.open()logsNOTE_COMPSCHEMA_MIGRATEDafter the transaction commits and only where records were copied, so a copy a rollback undid — or a PersistIt replay — no longer reports one. The message names both pairs of trees in full.Deciding all of that needs a question no read can answer: a storage may materialize a tree on first access — JE opens its databases with
setAllowCreate(true), so a cursor would create the very tree whose absence is being tested — or reject the access outright, as JDBC does when no table of that name exists. SoReadableTransactiongainstreeExists(), implemented by the four storages and delegated byTracedStorage; the two importer transaction adapters throwUnsupportedOperationException, as they already do for the operations they cannot serve.A rejected query is not an absent tree. The Cassandra backend keeps every tree of a backend as a partition of the one table named after the backend id, so it can only answer whether that partition holds a record — and where the table itself has not been created, the driver rejects the query rather than returning nothing.
InvalidQueryExceptioncarries the driver's wholeINVALIDprotocol code, so two conditions have to hold before a rejection is read as an absent tree, reporting a populated tree absent being the very corruption this PR is about:openTree()creates nothing there. A writeable transaction has just created the table throughopenTree(), so a rejection there is a fault and fails the open, as the unconditional cursor of the oldload()did. That is also where a table a coordinator has not caught up with lands — what a rolling upgrade produces, since schema agreement is never reached in a mixed-version cluster — and it fails loudly rather than passing for a table that was never created.Which name a JDBC statement takes says who owns the tree it names.
listTrees()reads thetree2tablecache andremoveStorageFiles()drops every table it names, so reading another backend's tree must not put it up for removal — otherwiseimport-ldif --clearBackendon one backend would drop the shared trees this migration promises to leave alone. A path that creates or writes a tree —openTree(),clearTree(),deleteTree(),put(),update(),delete(), and a cursor'sdelete()— takes the enrollinggetTableName(); a path that only reads takes the newreadTableName(). Guarding the existence probe alone would not have held: the migration counts the shared tree and copies it out, so the next statement would have enrolled it again.readTableName()answers from that same cache where the tree is in it, and computes the name — a SHA-224 digest — without entering it there otherwise. Every tree the backend owns passes throughopenTree(name, true)as it is opened, soread(), the per-entry hot path of every search, stays a map lookup rather than a JCA provider lookup and a digest per call; only the shared tree, twice per open of the backend, is computed.listTrees()still names the complete owned set.UpgradeTasksis deliberately untouched. Its rename step (UpgradeTasks.java:860) migrates 2.x local-db backends to pluggable JE by renaming JE databases through a JEEnvironmentdirectly, and never runs for PDB or JDBC; what it produces is the shared pair, which the first open then migrates like any other.Not fixed by this
An installation where two backends have already been sharing the tables is damaged before the upgrade. Migration hands each of them the same, already-corrupted snapshot: it stops the damage growing and prevents any further collision, but it cannot repair entries written earlier. Those need an export/reimport from whichever side is intact. A workaround remains available for anyone not yet upgraded — the tables are unqualified by schema, so giving each JDBC backend its own database, or its own schema/user in the URL, keeps them apart.
Naming these two trees after the backend id has a price of its own: alone among the trees of a backend they do not follow the entries when a JDBC backend is deleted and re-created under another id over the same database, every other tree being named from its base DN and found again. Token allocation would then restart at zero over entries encoded with the definitions of the old id, so changing a backend id over populated storage needs an export and a re-import — as it always has on Cassandra, where the one table of a backend is named after the id and the entries do not survive the rename either.
ds-cfg-backend-idis read-only in configuration, so this takes deleting and re-creating the backend; the javadoc oftreePrefix()records it.Two adjacent defects of the JDBC backend surfaced during review and are tracked on their own rather than folded in here:
isExistsTable()asked the catalog for every table of the database, with neither a catalog nor a schema filter (#887, since fixed on master and merged into this branch), andremoveStorageFiles()drops only the tables the running process has already touched, so an offlineimport-ldif --clearBackendclears nothing (#888). Both predate this change, although the migration is the first caller whose decision rests on whatisExistsTable()answers.Verification
PersistentCompressedSchemaTest— 8 cases against an in-memory transaction standing for one database addressed by several backends, so it runs without a container. The fixture models two behaviours of a real engine on purpose: it materializes a tree whenever one is asked for, whatevercreateOnDemandsays, which is whatJEStoragedoes, and a write can be made to fail.backendsSharingOneDatabaseDoNotShareTheTokenSpacedefinitionsWrittenBeforeTheUpgradeAreMigratedtheSharedTreesSurviveTheMigrationanInterruptedMigrationIsFinishedByTheNextOpenaReadOnlyOpenReadsTheSharedTreesWhereTheyLieopenTree()would leave behindaDefinitionOfThisBackendIsNeverOverwrittenByTheSharedOneaMigrationThatCannotCompleteFailsTheOpenERR_COMPSCHEMA_CANNOT_MIGRATE, naming the backend and both treesaStorageFailureIsLeftForTheStorageToRecognizeStorageRuntimeExceptionleaves the open with its own type, so the storage's retry can still recognize itEach guard was checked against the change it exists to catch, by taking that change out and watching the right test fail: without the backend qualifier,
backendsSharingOneDatabaseDoNotShareTheTokenSpacereportsexpected:<"cn"> but was:<"sn">— backend A's entry decoding as backend B's attribute, the corruption itself; without the never-overwrite guard,aDefinitionOfThisBackendIsNeverOverwrittenByTheSharedOnereports the same; withopenTree(adTreeName, shouldCreate)back in the read-only branch,aReadOnlyOpenReadsTheSharedTreesWhereTheyLiefinds the trees created; with the storage failure wrapped again,aStorageFailureIsLeftForTheStorageToRecognizecatches anInitializationException; and with no fail-closed at all,aMigrationThatCannotCompleteFailsTheOpensees the raw failure go by.Four cases added to
jdbc/TestCase, inherited by all four engine suites:testTreeExistsAnswersForAMissingTable— from a writeable and from a read-only transaction, and after adeleteTreetestCompressedSchemaTableIsQualifiedByBackendId— end to end on a real database: after the suite's backend has been opened and populated, the backend-qualified table exists and the shared one does nottestProbingATreeDoesNotPutItUpForRemoval— a second storage on the same database runs every read the migration runs against a tree it never opened (exists, count, read, cursor), thenremoveStorageFiles(); the tree is listed for removal by none of them and the table is still theretestDeletingThroughACursorPutsTheTreeUpForRemoval— the other side of the same rule: a cursor that deletes writes to the tree, so that one is listedOne case added to
cassandra/TestCase:testTreeExistsAnswersForAMissingTable— a tableopenTree()has not created answersfalseto a read and throws out of a writeable transaction; then, once created, the tree is there only while it holds a recordDummyWriteableTransaction.openTreenow honourscreateOnDemandand no longer replaces an already open tree with an empty one.Test runs, all green:
PersistentCompressedSchemaTestJETestCase,EncryptedJETestCasePDBTestCase,EncryptedPDBTestCasePDBStorageTestDefaultIndexTestOnDiskMergeImporterTestPgSqlTestCaseMySqlTestCasecassandra/TestCaseMsSqlTestCaseandOracleTestCasewere not run locally — no dialect-specific SQL was added, the path is shared with the two engines above — and are left to CI.