[#890] Persist a compressed schema token before handing it out, and report the ones with no definition - #894
Conversation
…anding it out, and report the ones with no definition CompressedSchema.getAttributeId() registered a new token in the in-memory maps and only then wrote it to the storage. A failed write left the registration behind, so every later encode of the same attribute description took the lock-free fast path and succeeded: the entries written with that token could not be decoded after a restart, or - where the lost token was the last one - were decoded as whatever attribute reused its id. The registration is now persisted before it is published. The element is appended to the decode map first, because storeAttribute() is free to persist the whole content rather than the element it is handed - DefaultCompressedSchema rewrites its file from getAllAttributes() - and the id reaches the encode map, the lock-free path an encode takes to it, only once the store returned. A store that fails withdraws the appended element, so the next attempt allocates the id again and stores it. getObjectClassId() does the same. PersistentCompressedSchema no longer absorbs the IOException of its ASN.1 writing: swallowed there, it left the same divergence with nothing reported to the operator at all. Independently of that, a token with no definition is reported as the unknown token it is rather than let out of the decode path as an IndexOutOfBoundsException, and the null slots a decode map is padded with - a compressed schema written by an older server can carry gaps - are carried over by a schema reload and skipped when the whole content is saved, rather than dereferenced.
maximthomas
left a comment
There was a problem hiding this comment.
The fix is right and the ordering argument holds: appending to the decode map first, storing, then publishing to the lock-free encode map is the correct order, and withdrawing on failure so the id is re-allocated is the correct rollback. Format compatibility is fine — the token is in the record, not positional, so skipping gaps on save does not shift ids in either direction.
One major, all in the tests: the withdrawal — the mechanism the PR is named for — is not pinned by anything. Plus a few places where the description claims more than the diff delivers.
The withdrawal is not tested (major)
opendj-server-legacy/src/test/java/org/opends/server/api/CompressedSchemaTestCase.java:184-197, twin at :217-228.
Both tests pass with the finally block deleted from registerAttribute/registerObjectClasses. Take the leak variant — encode map not published on failure, decode element left behind:
1st encode: id 0, adDecodeMap = [ad], store throws
retry: encode-map miss -> id = adDecodeMap.size() = 1, stores 1, publishes 1
attributeStoreCount == 2 holds. encodedToken is read from the retry's builder, so it is 1, and storedAttributes.containsKey(1) holds. Decoding 1 returns ad. All seven assertions pass in both tests, and neither ever reads a decode-map size.
That leaked slot is a definition DefaultCompressedSchema.save() would persist although its store never succeeded — exactly what remove(size() - 1) exists to prevent.
// closes it: the leak variant yields 1
assertEquals(encodedToken, 0);
// stronger
assertEquals(compressedSchema.savedAttributeTokens(), singletonList(0));The object-class ordering is untested (minor)
CompressedSchemaTestCase.java:87-93 vs :79-83. The double gates only the attribute hook:
protected void storeAttribute(...) { attributeStoreCount++; awaitIfGated(); failIfRequested(); ... }
protected void storeObjectClasses(...) { objectClassStoreCount++; failIfRequested(); ... }aTokenIsPublishedOnlyOnceItIsStored drives encodeAttribute only, so registerObjectClasses's ordering is exercised through the failure path alone.
Rewriting it as publish-first-then-store, with the encode entry also rolled back in the finally, keeps all 6 tests green. That variant is unsafe for the reason the PR body itself gives: since #670 the fast path is lock-free, so another thread can read the published id while the store is still running and write an entry carrying an id about to be withdrawn. Caught for attributes, not for object class sets.
(The naive revert does not stay green — a stale ocEncodeMap entry makes the retry fast-path and never store, failing :223. It is specifically the publish-and-roll-back variant that slips through.)
Call awaitIfGated() from storeObjectClasses and mirror the concurrency test.
The ordering test proves itself with a wall clock (minor)
CompressedSchemaTestCase.java:253-260, worker at :278-290.
assertTrue(started.await(30, SECONDS));
fail("the token was handed out before it was stored: " + concurrent.get(500, MILLISECONDS));The worker's first statement is started.countDown() — before new ByteStringBuilder(), before encodeAttribute. The latch proves the task body began, not that it reached the lock-free adEncodeMap.get(ad).
A regressed publish-first build normally fails here, but if the worker stalls 500 ms between the countdown and that read, the timeout arrives anyway and the regression is recorded as a pass. False pass only — never a false failure, since a correct build parks the worker in exclusiveLock.lock() until leaveStore.countDown(). With the previous point, this is the only test pinning the central invariant for either half.
Capture the worker's Thread and poll until getState() is BLOCKED/WAITING instead of timing out.
PersistentCompressedSchema's new catch cannot fire (minor)
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/PersistentCompressedSchema.java:113-124, :139-148.
Nothing in either try throws IOException. ASN1.getWriter(ByteStringBuilder) resolves to getWriter(builder.asOutputStream(), ...), whose root and every substream is ByteStringBuilder.OutputStreamImpl — its write() overrides do not declare IOException at all. store() declares DirectoryException. The catch compiles only because the ASN1Writer interface declares it.
So the change is defensive-only, which is fine — but the comment is wrong:
// Reported rather than absorbed: the caller takes a store that returned for a definition
// that reached the tree, and an entry written with a token that never did cannot be decoded
// once the server is restarted.On this path store() was never invoked and nothing reached the tree. Worth stating plainly: the withdrawal is reachable in a real server through store()'s pre-existing catch (Exception) -> DirectoryException on a storage.write failure, not through this new catch.
The IndexOutOfBoundsException rationale is overstated (minor)
The description says the unchecked exception escaped "on a read path written for DirectoryException". org/opends/server/types/Entry.java:3483 opens a try that encloses both decodeObjectClasses (:3533) and both decodeAttributes calls (:3539, :3541), and ends in:
catch (DirectoryException de) { throw de; }
catch (Exception e) {
logger.traceException(e);
throw new DirectoryException(getServerErrorResultCode(), ERR_ENTRY_DECODE_EXCEPTION.get(...), e);
}Entry.java:3688 and :3585 are the only production callers repo-wide. The conversion already existed; the net in-tree change is the message text, minus that traceException stack. Hardening the contract for out-of-tree callers of a @PublicAPI class is real and worth doing — it just is not what is claimed.
Checked the inverse too, and it is clean: bulk readers (ID2Entry, verify, rebuild, import/export, backup) do not start silently skipping corrupt entries, because they already saw a DirectoryException.
Consider keeping a traceException on the new path.
The load SPI still admits a negative or huge token (minor, pre-existing)
opendj-server-legacy/src/main/java/org/opends/server/api/CompressedSchema.java:643-659 and :748-765 — untouched by this PR, raised because it is the sibling of what decodeMapGet just hardened.
if (id < mappings.adDecodeMap.size()) { mappings.adDecodeMap.set(id, ad); }
else { while (id > mappings.adDecodeMap.size()) { mappings.adDecodeMap.add(null); } ... }decodeId folds every byte and returns id - 1, with no length cap and no sign check. From a corrupt store:
- key
0x00givesid == -1; the guard passes andset(-1, ad)throws, escapinginitializeSchema()as aRuntimeException(DefaultCompressedSchema:147) or as aStorageRuntimeException(RootContainer:145-152); - key
0x7FFFFFFFgivesid == 2147483646; the else branch pads aCopyOnWriteArrayListone element at a time, unbounded, holdingexclusiveLockand — for the pluggable backend — insideRootContainer.open's write transaction. Backend open hangs silently.
Follow-up issue rather than this PR.
Nits
- Unknown-token tests assert nothing about the exception:
CompressedSchemaTestCase.java:299-308,:317-326—catch (final DirectoryException expected)with noResultCodeor message-id check, so drift away fromERR_COMPRESSEDSCHEMA_UNRECOGNIZED_AD_TOKENis invisible. No live false pass today;decodeAttributehas one throw site. - Boundary coverage:
-1/0/7are probed against an empty decode map, sotoken == sizeandtoken > sizeon a populated one are never exercised. Same code path, so a nit rather than a hole. - The decode map became shrinkable:
CompressedSchema.java:514/529,:570/574dosize()thenget()on aCopyOnWriteArrayListthatremove(size() - 1)can now shrink — the splitdecodeMapGet's own comment calls unsafe. Unreachable in-tree (getAll*runs only underexclusiveLockviasave()); reachable from a subclass, since the class ismayExtend = true. - Withdrawal removes by index, not identity: "every append is made under the exclusive lock" covers cross-thread serialisation but not same-thread re-entrancy. A subclass whose
storeAttribute()re-entersloadAttribute()and then throws removes the wrong element. Worth a javadoc line saying an implementation must not re-enter the encode or load path. - Message names the database:
ERR_COMPSCHEMA_CANNOT_STORE_EX_459reads "...in the database: %s" but now also guards in-memory ASN.1 encoding. Argument count is correct. - Token reported is the decoded id:
ERR_COMPRESSEDSCHEMA_UNRECOGNIZED_AD_TOKEN.get(adId)reports-1for an all-zero on-disk token, a value appearing nowhere in the stored data. adCounter/ocCounternow under-report:DefaultCompressedSchema.save()counts emitted records, so gaps make the trailing integers smaller thanmaxId + 1. Harmless here — both ends read them as "No longer used" — but an older release seeding a counter from them would re-issue live tokens. Untested against an old release.- Inconsistent
final:CompressedSchema.java:506private List<AttributeDescription> adDecodeMapvs:564private final List<Map<ObjectClass, String>> ocDecodeMap.
…gistration fails, and the rest of what the review found open The two tests named for the withdrawal passed with the finally block deleted from registerAttribute()/registerObjectClasses(): a registration that leaks its decode map element simply allocates the next id, and the store count, the token of the retry and the decode all still hold. They now assert the id the retry is given and the tokens the whole content would be saved under, which is what the leaked element shows up in. storeObjectClasses() is gated like storeAttribute(), and the ordering of registerObjectClasses() has a concurrency test of its own: publishing the id before storing it and rolling both maps back kept every object class test green. The ordering tests wait for the concurrent encode to park on the exclusive lock rather than for a timeout to pass. A latch counted down inside the task only proves the task body started, so a build handing out an id before storing it was recorded as a pass whenever the thread was slow between the countdown and the lock-free read. Nothing under the try of PersistentCompressedSchema's ASN.1 writing throws IOException - the sink is a ByteStringBuilder, whose OutputStream declares none - so the comment on that catch no longer claims a store reached the tree. What makes a withdrawal reachable in a running server is store()'s own catch, on a storage.write that failed. An unknown token is named as the storage holds it, with the id it decodes to: an all-zero token read as -1, a value appearing nowhere in the stored data. The IndexOutOfBoundsException the decode path now converts is traced, which the generic catch of Entry.decode() used to do. The counters DefaultCompressedSchema records are taken from the highest token written rather than from the number of records, which a gap makes smaller. getAllAttributes()/getAllObjectClasses() read the decode map the way decodeMapGet() does, and registerAttribute() says what a store must not re-enter.
|
Thank you - the major is real, and I could reproduce your trace exactly. All eight points are addressed in 8ab8047, and the PR description is corrected where you showed it claimed more than the diff delivers. The withdrawal is not tested (major) - confirmed and closedYour trace holds line for line: Both suggestions taken, assertEquals(encodedToken, 0, "the withdrawn id was not allocated again");
assertEquals(compressedSchema.savedAttributeTokens(), Collections.singletonList(0),
"the whole content still holds the element of the failed registration");Rather than argue it, I built the two variants and ran them:
The object-class ordering is untested (minor) - closed
The ordering test proves itself with a wall clock (minor) - closedReplaced with PersistentCompressedSchema's new catch cannot fire (minor) - confirmedChecked against the byte code of the pinned No The IndexOutOfBoundsException rationale is overstated (minor) - confirmed
The load SPI still admits a negative or huge token (minor, pre-existing) - #897Agreed it is a follow-up, raised as #897. One correction that makes the second case worse than "unbounded padding": the decode map is a Nits
Untouched from the last round and worth repeating for the record: the JDBC second-borrow note stays under #872 / #877, not here. |
Fixes #890
CompressedSchema.getAttributeId()registered a new token in the in-memory maps and only then wrote it to the storage. When the write failed the exception was propagated - the operation failed, as it should - but the registration stayed behind, so every later encode of the same attribute description took the lock-free fast path and succeeded. Entries were then written referencing a token whose definition never reached the storage.getObjectClassId()had the same shape.What it cost
nullanddecodeAttribute()reportsERR_COMPRESSEDSCHEMA_UNRECOGNIZED_AD_TOKEN, or - if the lost token was the highest one -adDecodeMapis simply shorter andCopyOnWriteArrayList.get(adId)throwsIndexOutOfBoundsException.reloadAttributeTypeMaps()walkedadDecodeMapby index and dereferenced a padded slot, so a gap brought downreloadMappingsIfSchemaChanged(), which runs on every decode.reloadObjectClassesMap(),getAllAttributes()andgetAllObjectClasses()did the same - the last two are whatDefaultCompressedSchemarewrites its file from, so a gap stopped it from saving at all.The fix
The registration is persisted before it is published:
storeAttribute()is free to persist the whole content of the compressed schema rather than the single element it is handed -DefaultCompressedSchema.save()rewrites its file fromgetAllAttributes()- so the element has to be part of it by then. The decode map is not what an encode reaches an id through, so nothing can yet write an entry carrying it;aTokenIsPublishedOnlyOnceItIsStoredandanObjectClassTokenIsPublishedOnlyOnceItIsStoredpin exactly that, and fail on master;registerAttribute()records what that argument rests on: the lock is reentrant, so an implementation of the store must re-enter neither the encode, the load nor the decode path of the compressed schema.PersistentCompressedSchemano longer absorbs theIOExceptionof its ASN.1 writing. That catch is defensive - the writer encodes into aByteStringBuilder, and none of the write methods of theOutputStreamit hands out declaresIOException, so nothing under thetrycan raise one today. What makes the withdrawal reachable in a running server isstore()'s owncatch (Exception), on astorage.writethat failed. Absorbing theIOExceptionwould nonetheless have let a store that did not happen return normally, and the comment on the catch now says which of the two is which.Independently of the above, and because a compressed schema written by an older server can carry gaps for other reasons:
decodeAttribute()anddecodeObjectClasses()report a token that is out of range, or below it, as the unknown token it is instead of lettingIndexOutOfBoundsExceptionout. In tree this changes the message rather than the exception -Entry.decode()already converted anything unchecked into aDirectoryExceptionthrough its generic catch, and the stack that catch used to log is kept by tracing indecodeMapGet(). What it does close is the contract of a@PublicAPIclass for callers outside the tree. The token is named as the storage holds it, with the id it decodes to, since an all-zero token reads as the id-1- a value appearing nowhere in the stored data;nullslot over as a gap rather than dereferencing it - dropping it would shift the ids of the elements after it, and would let the next registration hand out an id an already written entry carries;DefaultCompressedSchemarecords with it are taken from the highest token written rather than from the number of records, which a gap makes smaller. Both ends read those counters as "No longer used", but a release old enough to seed from them would otherwise re-issue live tokens.Testing
CompressedSchemaTestCase(7 tests), run withmvn -Pprecommit verify -pl opendj-server-legacy -Dit.test=CompressedSchemaTestCase:Tests run: 7, Failures: 0, Errors: 0;NullPointer ... because "ad" is null,the token was handed out before it was stored: 0,the failed registration was left behind expected [2] but found [1](attribute and object class set alike),ArrayIndexOutOfBounds Index -1 out of bounds for length 0.Passing on master is not on its own enough for the tests named for the withdrawal, so the two variants that would leave the defect in place were built and run as well:
finallyblock deleted fromregisterAttribute()- the registration leaks its decode map elementattributeTokenIsWithdrawnWhenItCannotBeStored:195fails,the withdrawn id was not allocated again expected [0] but found [1]registerObjectClasses()publishing the id before storing it, rolling both maps back on failureanObjectClassTokenIsPublishedOnlyOnceItIsStored:297fails,the token was handed out before it was stored: 0The second variant leaves
objectClassTokenIsWithdrawnWhenItCannotBeStoredgreen, which is why the ordering ofregisterObjectClasses()needed a concurrency test of its own rather than the failure path alone.Note
Reachable on any backend whose write can fail. The JDBC backend makes it ordinary rather than exotic, and for a reason worth recording separately: the entry is encoded inside an already open write transaction (
ID2Entry.put()callsencode()withintxn), whilestore()goes throughstorage.write(), which borrows a second connection from the pool while the first is held. That belongs to #872 / #877, not here.The load SPI -
loadAttribute()/loadObjectClasses(), the sibling of the read path hardened here - still admits a negative or arbitrarily large token from a corrupt store. Pre-existing and untouched by this PR; raised as #897.