Conversation
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Real bug, correctly diagnosed and cleanly fixed: BodyContentHandler is an accumulating sink with no reset, so keeping one instance in a field made every later read() return the concatenation of all previously parsed documents (and made concurrent reads of one reader interfere). Switching the field to a per-read Supplier<ContentHandler> is the right shape, the legacy ContentHandler constructor keeps its old semantics on purpose, and the new testReusedReaderDoesNotAccumulateEarlierContent fails on the unpatched revision — that is exactly the regression guard this needed. Javadoc on both constructors is clear about which one isolates per read.
Two non-blocking points plus one test gap are pinned inline: the new overload makes a bare null 4th argument ambiguous for existing callers (a source-compat change worth calling out in release notes, or avoidable with a static factory), the read-time Objects.requireNonNull surfaces a factory contract violation as a wrapped ReaderException, and neither the new Supplier constructor nor the concurrency aspect of the bug is covered by a test.
Not approving yet: CI (build on ubuntu/windows) is still pending on c2689e8, and CLA/mergeability are fine (license/cla signed, MERGEABLE, merge_state=BLOCKED only because checks are in progress).
Checklist notes
- [Warning]
TikaReader.java:106—ContentHandler/Supplier<ContentHandler>overload ambiguity fornullliterals (source compatibility). - [Info]
TikaReader.java:183— read-time null handling + constructor validation order. - [Warning]
TikaReaderTest.java:198— newSupplierconstructor and thread-safety claim are untested.
Automated review by github-manager-bot and first-time-contributor follow-up: welcome, this is a well-written PR.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
c2689e8 to
01f3170
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review after the force-push (c2689e82 -> 01f31709). All three points from my previous review are resolved, and the API shape is better for it:
- compile-time ambiguity — the
Supplier<ContentHandler>variant is no longer a public constructor overload, sonew TikaReader(size, strategy, overlap, null)resolves unambiguously to the existingContentHandlerconstructor; the per-read variant is reached throughTikaReader.perReadHandler(...). - null from the factory — the
IllegalStateExceptionis now thrown outside thetry, so it is no longer swallowed and re-wrapped as a genericReaderException("Failed to read document from: ..."); a misconfigured factory is distinguishable from a parse failure, and there is a test pinning it. - test coverage of the new entry point — null factory, factory returning null, one handler per read, caller-supplied handler still honoured, sequential reuse, and a 4-thread concurrency check. That is a proper regression suite for a bug that was invisible until someone read two documents.
The fix itself is right: BodyContentHandler is an accumulating sink with no reset, so one instance per read is the only correct default, and parser.parse(...) + handler.toString() inside the same callable keeps the handler's lifetime scoped to the read.
Two non-blocking notes inline: the factory is per subscription rather than per read(...) call (matters for retryWhen/re-subscription and is worth one sentence plus one test), and the legacy constructor still carries the original accumulation for existing callers — a reuse warning or a @Deprecated there would close the loop.
CI: ubuntu, License, Module Sync and codecov pass; build (windows-latest) was still queued when I looked. CLA signed. Approving — thanks for the quick turnaround, @KANLON.
Automated review by github-manager-bot
01f3170 to
4cda8f1
Compare
SummaryRe-review after the force-push to Commenting rather than approving: Findings
SuggestionsNo structural change requested. The Automated review by github-manager-bot |
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Re-review after the force-push to 4cda8f17: the per-read handler fix is intact and the new tests are the strongest part of this PR — accumulation, resubscription, factory-invocation counting and a 4-thread concurrency case all assert the actual invariant instead of just the return value. Four non-blocking nits below (one version-string issue, three wording/contract details).
Commenting rather than approving: license/cla has no status on 4cda8f17 yet (the check was green on the previously approved head 01f31709), and both build jobs are still in progress, so I cannot re-approve this head. Once CLA + build go green there is nothing here I would block on.
Findings
- [Warning]
TikaReader.java:91—@Deprecated(since = "2.0.3")is off by one,mainis already2.0.4-SNAPSHOT; also worth an explicitforRemovaldecision - [Info]
TikaReader.java:131— the null-factory branch reuses the "content handler cannot be null" message, so two different failure modes are indistinguishable - [Info]
TikaReader.java:147— the deprecated path keeps the old accumulation bug silently; a one-timelog.warnwould surface it for callers who do not build with-Xlint:deprecation - [Info]
TikaReader.java:165— a factory that throws escapes unwrapped while everything after it becomesReaderException, and the@throws IllegalStateExceptiononperReadHandlerdescribes anonErrorsignal, not a synchronous throw
Suggestions
No structural change requested. The Supplier<ContentHandler> + private canonical constructor shape is the right way to keep the old public signature compiling, and Mono.fromCallable is already the correct per-subscription boundary, so consulting the factory inside the callable is right.
Automated review by github-manager-bot
| Supplier<ContentHandler> handlerFactory) { | ||
| super(chunkSize, splitStrategy, overlapSize); | ||
| if (handlerFactory == null) { | ||
| throw new IllegalArgumentException("content handler cannot be null"); |
There was a problem hiding this comment.
[INFO] This message is "content handler cannot be null", but at this point the null argument is the factory, not a handler — testNullPerReadHandlerFactory asserts IllegalArgumentException from exactly this line. Two distinct failure modes then produce an identical message (:149 is the real handler case). Suggest "content handler factory cannot be null" here so a stack trace tells you which side misbehaved; the wording at :149 can stay as-is.
| } | ||
|
|
||
| /** Wraps a single handler instance so it is returned for every read. */ | ||
| private static Supplier<ContentHandler> singletonHandler(ContentHandler handler) { |
There was a problem hiding this comment.
[INFO] singletonHandler preserves the old shared-handler behaviour silently, and there is no in-repo caller left that would trip over it (docs/v2/{en,zh}/integration/rag/simple.md:44 and every test use the default constructor). Callers who do not compile with -Xlint:deprecation therefore keep the bug with no signal. A one-time log.warn("TikaReader created with a shared ContentHandler; text will accumulate across reads — use TikaReader.perReadHandler(...) instead") here costs nothing and turns a silent data-corruption path into a visible one. Fine to skip if the deprecation warning is considered sufficient.
| String path = input.asString(); | ||
| // A handler accumulates everything written to it, so each read gets | ||
| // its own instance. | ||
| ContentHandler handler = handlerFactory.get(); |
There was a problem hiding this comment.
[INFO] Two small contract nits around this line:
- The factory call sits outside the
try, so a factory that throws escapes raw while every extraction failure is wrapped intoReaderException— downstreamonErrorMap(ReaderException.class, ...)style handling will not see it. Wrapping theget()too (or documenting the asymmetry) keeps the error channel uniform. Thenull-return case asserted bytestNullFromPerReadHandlerFactoryis unaffected. - Because of this placement,
perReadHandler's javadoc@throws IllegalStateException if the factory returns null during a readnever throws synchronously — it arrives asonError, which is also what theReaderinterface documents ("errors ... propagated through the Mono"). Suggest rewording to "signals" to avoid implying a synchronous throw.
4cda8f1 to
4a94429
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Third pass, on the reworded head 4a944296 (force-pushed over 4cda8f17, which I reviewed at 04:03 UTC). Good news first: three of my four previous points are resolved in this revision, and the resolution is the one I would have picked —
@Deprecated(since = ...)now reads2.0.4, matching<revision>2.0.4-SNAPSHOT</revision>inpom.xml.- Legacy path is no longer silent —
singletonHandlerwarns once on the second subscription, which is exactly the missing signal I asked for. - Factory-null is now distinguishable — construction-time
IllegalArgumentException("handler factory cannot be null")vs read-timeIllegalStateException("content handler factory returned null"), and the javadoc says "per read subscription" consistently now, so the retry/resubscribe semantics are documented correctly.
What is left is one genuinely open item and three small ones, all inline: the throwing-factory case is still not wrapped while the javadoc at :115 claims it belongs to the same error channel as everything else; the null-handler check on the legacy constructor moved behind validateChunkingParameters, which changes which message a caller gets and is the likely reason codecov still reports two uncovered lines in the file; forRemoval is undecided and the new warn-once path has no test.
Core fix remains correct and well guarded: a ContentHandler is an accumulating sink with no reset, so per-read creation is the only sound default, and testReusedReaderDoesNotAccumulateEarlierContent / testPerReadHandlerFactoryIsConsultedOnResubscription / testConcurrentReadsStayIsolated assert the actual invariant rather than the return value. Docs (docs/v2/{en,zh}/integration/rag/simple.md:44) only use the default constructor, so they stay accurate — no companion change needed there.
Not approving on this head yet: build (windows-latest) is still in progress (ubuntu build, license, module-sync, codecov/patch and license/cla are green, CLA signed). Nothing above is a blocker — once the windows build goes green, fix or document the factory-throw asymmetry and this is good to go from my side.
Automated review by github-manager-bot
| super(chunkSize, splitStrategy, overlapSize); | ||
| if (handler == null) { | ||
| throw new IllegalArgumentException("content handler cannot be null"); | ||
| this.handlerFactory = singletonHandler(handler); |
There was a problem hiding this comment.
[Warning] Minor behaviour change on the legacy constructor, easy to miss: the null-handler check now runs after validateChunkingParameters(...) (line 95) instead of before it. Pre-patch, new TikaReader(0, PARAGRAPH, 50, null) failed with "content handler cannot be null"; it now fails with the chunking message, because singletonHandler(...) (line 152) is only reached after super(...) returns — Java will not let you check the argument before the super() call here.
The only test covering this path (testNullContentHandler) uses valid chunk params, so it still passes, and codecov reports the null branch at :153 as one of the two uncovered lines.
If the reorder is intentional, no code change needed — but then the @throws IllegalArgumentException javadoc at :87 should say which check wins. Otherwise add a dedicated case:
@Test
void nullHandlerStillRejectedWithItsOwnMessage() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new TikaReader(512, SplitStrategy.PARAGRAPH, 50, (ContentHandler) null));
assertEquals("content handler cannot be null", e.getMessage());
}| String path = input.asString(); | ||
| // A handler accumulates everything written to it, so each read gets | ||
| // its own instance. | ||
| ContentHandler handler = handlerFactory.get(); |
There was a problem hiding this comment.
[Warning] This was the one point from my previous review that is still open, and the new javadoc at :115-117 now describes behaviour the code does not implement. handlerFactory.get() sits outside the try:
- factory returns
null→IllegalStateException, documented and tested (testNullFromPerReadHandlerFactory) ✅ - factory throws → the raw exception propagates and is not wrapped, while every other failure in this method becomes a
ReaderException
The javadoc says "If the factory returns null or throws during a read subscription, the returned publisher fails with the corresponding error" — a caller that groups failures with onErrorMap(ReaderException.class, ...) (the pattern this very method uses at :194) will not see a throwing factory. Pick one:
// (a) uniform error channel
ContentHandler handler;
try {
handler = handlerFactory.get();
} catch (Exception e) {
throw new ReaderException("Content handler factory failed", e);
}
if (handler == null) {
throw new IllegalStateException("content handler factory returned null");
}// (b) keep the code, make the javadoc state the asymmetry
* <p>A factory that returns null fails the publisher with {@link IllegalStateException};
* a factory that throws propagates unchanged, i.e. it is not wrapped in {@link ReaderException}.
| * constructor reuses the supplied handler across read subscriptions, which can carry | ||
| * content over between documents when the handler is stateful. | ||
| */ | ||
| @Deprecated(since = "2.0.4") |
There was a problem hiding this comment.
[Info] since = "2.0.4" now matches <revision>2.0.4-SNAPSHOT</revision> in pom.xml — that resolves the off-by-one, thanks. Two leftovers from the same thread, both non-blocking:
forRemovalis still undecided. Repo precedent in this module isMilvusStore.java:208→@Deprecated(since = "1.0.11", forRemoval = true). If this constructor is meant to go once nobody needs a shared handler, commit to it here; if shared handlers stay supported permanently, a@implNoteplus the warn you already added is more honest than an open-ended deprecation.- The warn-once path at
:155-162is the only runtime signal for legacy callers and has no test — two reads plus a LogbackListAppenderasserting exactly one warning would pin both it and the uncovered:153branch.TikaReaderTestwill need@SuppressWarnings("deprecation")for the constructor call, which is also a useful signal that the tests are deliberately exercising the legacy path.
Nit only: "TikaReader is reusing one content handler across read subscriptions; ..." reads better as "...reused a single content handler across read subscriptions; text from earlier reads may have appeared in later ones..." — the warning fires on the second subscription, i.e. after the damage.
| * @return a new TikaReader | ||
| * @throws IllegalArgumentException if parameters are invalid or the factory is null | ||
| * | ||
| * <p>If the factory returns null or throws during a read subscription, the returned publisher |
There was a problem hiding this comment.
[Info] Javadoc layout nit: this <p> paragraph comes after the @return / @param / @throws block tags, so the generated docs render it as part of the @throws description instead of as prose. Move it above the first @param.
While you are in this block — @throws IllegalArgumentException if parameters are invalid or the factory is null is correct but only reachable through this factory method (the default constructor passes a lambda), which is a nice property of the perReadHandler(...) shape: it keeps the Supplier variant off the overload set, so new TikaReader(512, PARAGRAPH, 50, null) still resolves unambiguously to the ContentHandler constructor. Worth one line in the release notes alongside the deprecation, since that is what a caller grepping for the constructor will hit.
4a94429 to
19d530e
Compare
oss-maintainer
left a comment
There was a problem hiding this comment.
Summary
Delta re-review of the force-push (4a944296 -> 19d530e8). The only change is in perReadHandler's javadoc, and it closes both open items from my previous pass:
- the
<p>block moved above the first@param, so it renders as prose instead of being folded into@throws(TikaReader.java:113-117); - the error-channel asymmetry is now stated the way the code behaves — "returns null ->
IllegalStateException", "throws -> propagates unchanged, not wrapped inReaderException" — instead of promising a uniform failure. Documenting the asymmetry is a legitimate call; it keeps a caller-supplied factory exception distinguishable from an extraction failure. If you would rather have one error channel, wrapping thehandlerFactory.get()call in atryand rethrowing asReaderExceptionis a two-line change, but leaving it documented is fine.
The behavioural core of the fix is unchanged and was already verified: one ContentHandler per read subscription (so no cross-document accumulation), singletonHandler(...) retaining the legacy contract with a one-time log.warn on the second call, @Deprecated(since = "2.0.4") matching pom.xml, and the factory variant kept off the overload set so new TikaReader(512, PARAGRAPH, 50, null) still resolves unambiguously to the ContentHandler constructor.
Findings
None blocking. Two non-blocking leftovers from the earlier thread, recorded for the record:
- [Info]
TikaReader.java:153(null-handler branch) and:155-162(warn-once path) still have no test; a LogbackListAppendercase asserting exactly one warning after two reads would pin both. - [Info]
forRemovalon the deprecated constructor is still undecided — repo precedent in this module isMilvusStore.java:208.
Verdict
LGTM. CLA signed, license/cla green, full CI green on 19d530e8, mergeStateStatus=CLEAN. Merging is a maintainer action; I will not merge.
Automated review by github-manager-bot - @oss-maintainer re-review on head 19d530e8
AgentScope-Java Version
2.0.3-SNAPSHOT (main @ 70686e4)
Description
TikaReaderkeeps a singleContentHandlerinstance in a field and passes it toAutoDetectParser.parse(...)on every read:BodyContentHandler(the default) is a sink that accumulates everything written to it — itwraps a
StringWriterand exposes no reset API. Because the instance is shared, the extractedtext grows monotonically instead of being per-document:
A freshly constructed reader is correct, which is why the existing single-read tests never caught
it. Any application that builds one
TikaReaderand indexes several documents (the documentedusage pattern) gets documents whose chunks contain the concatenation of every document read
before them, so the resulting embeddings and retrieved context are wrong — silently, with no
error.
The same shared instance also makes concurrent reads of one reader interfere with each other.
Why the accumulation is not intended behaviour
Sharing one handler looks like a "configure once" choice, but accumulation is not a usable feature:
readis documented as a function of its input, and the output contradicts accumulation.createDocumentsderivesdoc_idfrom the current path only (SHA256(path)) and indexeschunks as
0..nof that path. With a shared handler, the documents returned for the second filecarry
doc_id = SHA256(second)while their text also contains the first file — metadata andcontent disagree. There is no way to read accumulated state back: the only exit is
read(),which presents it as the text of the file it was asked to read.
"ALPHA"then"ALPHA\nALPHA".No document reader is meant to be non-idempotent on identical input.
TextReaderre-readsinput.asString()on every call, so onereader can serve any number of documents.
TikaReaderis the only one that carries stateacross calls.
feat(rag): Support tika document parser (#538)describes the change only as supporting the parser; the field is commented
Handler to manage content extractionand its tests never read twice. The shape is simply thestandard Tika usage —
new BodyContentHandler(...)immediately before a singleparser.parse(...)— lifted into a field, which turns a per-parse buffer into a shared one.This change also keeps the caller-supplied constructor on its old semantics, so a caller who
really did hand in a shared handler is unaffected; only the default constructor, where the caller
never chose a handler at all, gets per-read isolation.
Fix
Supplier<ContentHandler> handlerFactory, consulted once per read.TikaReader()now asks for a freshBodyContentHandler(-1)per read, so consecutive reads areindependent.
TikaReader(int, SplitStrategy, int, Supplier<ContentHandler>)lets callersopt into per-read isolation for custom handlers too.
TikaReader(int, SplitStrategy, int, ContentHandler)constructor keeps itsbehaviour and its null check (
singletonHandler); its javadoc now states that the suppliedhandler is reused as-is and that a stateful handler will carry text across reads.
extractTextFromTikauses the per-read handler instead of the field; a handler factory thatreturns
nullat read time is rejected with the same message as the constructor check(
Objects.requireNonNull).Compatibility
Passing a
nullliteral as the 4th argument is now ambiguous between the two overloads and needsa cast —
new TikaReader(512, SplitStrategy.PARAGRAPH, 50, (ContentHandler) null). This onlyaffects a literal
null(which throws anyway); existing code that passes a real handler compilesunchanged. The existing test
testNullContentHandlerwas updated accordingly.The only in-repository usage outside the tests is the documentation example
(
docs/v2/{zh,en}/integration/rag/simple.md), which uses the default constructor and only readsone document — unaffected.
Tests
Added to
TikaReaderTest:testReusedReaderDoesNotAccumulateEarlierContent— reads two documents with one reader andasserts the second result equals its own text. Fails on the unpatched revision:
reading the same document twice must be idempotent expected:<ALPHA[]> but was:<ALPHA[ALPHA]>.testCallerSuppliedHandlerIsUsed— a caller-suppliedToXMLContentHandleris still honoured.Verified locally:
Checklist
mvn spotless:applymvn test)handler-reusing constructor and on the new factory constructor