Repository navigation
DLPX-99491 Hop 2.15: delimited file masking (with header/trailer) ~3x slower because Condition.evaluate builds a ValueMeta per row - #16
Conversation
a303e92 to
050c02c
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Existing generated setters can leave cached values stale and expose internal cache state publicly.
Review effort: Balanced
Findings: 1
What changed in this PR
Caches constant condition metadata/data to eliminate per-row construction in Condition.evaluate().
Changes:
- Adds thread-visible lazy caches for constant right-hand values.
- Adds correctness and cache-reuse tests.
| File | Description |
|---|---|
core/src/main/java/org/apache/hop/core/Condition.java |
Reuses cached constant metadata and data during evaluation. |
core/src/test/java/org/apache/hop/core/ConditionTest.java |
Tests repeated evaluation and cache reuse. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
050c02c to
3f6bfad
Compare
3f6bfad to
46f9dd7
Compare
…ion.evaluate builds a ValueMeta per row PR URL: https://www.github.com/delphix/hop/pull/16
46f9dd7 to
3c68ff2
Compare
zach-frank-delphix
left a comment
There was a problem hiding this comment.
Interesting approach - I think we should expand the new tests, and I think we should gate this on results of end to end testing as well.
Can you also please file/ link the follow up tickets here for posterity?
sandeep-delphix
left a comment
There was a problem hiding this comment.
Changes looks good and comments are covered already.
AnoopKumarDelphix
left a comment
There was a problem hiding this comment.
Overall looks good. The root-cause analysis is thorough, the fix is narrowly scoped, and the cache-invalidation and safe-publication reasoning holds up. Approving, with a few follow-ups:
- Stress/performance results: The Testing section says end-to-end performance hasn't been measured with this build yet. Please run the 1M-row delimited-file masking job against the published artifact (e.g.
2.15.0-dlpx1) and add the before/after numbers to the description: wall clock, reader throughput, and CPU and RUNNABLE % forRe-route Header and Trailer. - Multi-copy benchmark: Was a
FilterRowswith copies > 1 benchmarked when the field type differs from the constant's type (e.g. a String field compared to a Number constant)? Every copy now shares the one cachedValueMeta. Its conversion methods (convertNumberToString,convertDateToString, etc.) aresynchronized, so those copies now compete for one lock where each row used to get its own object. This should still beat building a new object per row, but it would be good to confirm with numbers. - If contention shows up: A per-thread cache instead of a single shared instance would avoid it. I don't think that's needed unless the benchmark shows a problem.
|
This patch on 2.15.0 is not needed as this issue is already fixed by upstream in 2.17.0. |


Problem
Jira: DLPX-99491
Delimited-file masking on Hop 2.15 is ~3x slower than on Hop 1.2.0. Measured on 1M rows over SFTP, same job configuration and the same five algorithms on both engines:
Database connectors are unaffected, and this is not engine-version drift: 2026.6 matched 2026.4 before the Hop upgrade, so the regression comes from the Hop version change itself.
Reproduce
FilterRowstransform whose condition compares a field against a String constant).FilterRowstransform consumes a full CPU core for the whole run.Any
FilterRowscondition with a constant right value is affected; the masking engine is simply a workload that evaluates one per row.Evaluation
Hop 2.15 refactored
org.apache.hop.core.Condition. In 1.2.0 the right-hand side of a condition was a pre-builtValueMetaAndData rightExactfield, andevaluate()made zerocreateValueMetacalls. In 2.15 it became aCValue rightValue, andevaluate()constructs fresh metadata on every row, at two sites:Condition.java:359—rightValue.createValueMeta()Condition.java:364—rightValue.createValueData(), which builds aValueMetaAndData, i.e. precisely the object 1.2.0 cached onceEach construction runs
ValueMetaBase.<init>, which reads two system properties (HOP_LENIENT_STRING_TO_NUMBER_CONVERSIONandHOP_EMPTY_STRING_DIFFERS_FROM_NULL).createValueData()builds threeValueMetaobjects internally, so a single row can perform six property reads. Under aSecurityManagereach read is a full policy evaluation:So every row pays reflective class instantiation plus several policy evaluations, and because
keepRowissynchronizedthe cost cannot be diluted with additional transform copies.Profiling evidence (jstack sampling the job JVM on both engines; 135 samples on 2.15, 61 on 1.2.0)
On 2.15 the generated
Re-route Header and TrailerFilterRowstransform burns 46.75s of CPU across a 49.8s run — a fully pegged core, 2.5x the masking algorithm's own 18.3s — and is RUNNABLE 96%. On 1.2.0 the same transform is TIMED_WAITING 97%, doing essentially nothing.Share of that transform's samples on 2.15:
CValuecallCustomPolicy.getDomainSourcePath(URLDecoder + Paths.get)AccessControllerstack walk (JVM-side)PluginRegistry.loadClassDistinctive frame counts, 2.15 vs 1.2.0:
FilterRows.keepRow106 vs 1,createValueMeta175 vs 0,CustomPolicy97 vs 0,URLDecoder.decode78 vs 0.Independently confirmed by removing the trigger rather than the cost: with a file format carrying no header/trailer record — which stops the engine emitting the constant-valued condition — 2.15 drops from 49.8s to 24s and the algorithm reaches Run 97% / InputWait 2% with 4 worker threads, the saturated steady state 1.2.0 shows. With the same algorithms assigned on both engines, the 3x gap collapses to 1.2.0 being only ~10% faster.
Solution
Condition.evaluate()now callsgetCachedValueMeta()/getCachedValueData()instead of thecreate*methods. Both results derive only fromCValue's fields —name,type,text,length,precision,nullValue,mask— so they are built once and reused.CValueis annotated@Getter @Setter, so those fields are publicly mutable. The cache therefore records the field values it was derived from and rebuilds whenever any of them differ:Keying on the values rather than invalidating inside setters also covers the reflective field writes
@HopMetadataPropertyperforms during deserialization, and it leaves the public API and the one-line class-level@Setterintact. The guard is cheap relative to what it protects: seven primitive/reference comparisons per row, against the sixSystem.getPropertycalls plus reflective instantiation it avoids.Four deliberate design points:
createValueMeta()andcreateValueData()are left public and uncached. Their only external callers areConditionEditor:355, which passes the result toEnterValueDialog(a dialog that may modify it), andConditionEditor:948. Caching those would risk the UI corrupting a shared instance, so the cached accessors are package-private and used only on the per-row path.The cache field is
volatile, because aConditionis shared.FilterRows.keepRowreadsmeta.getCondition()— one instance across every copy of the transform — and each copy'ssynchronizedis on its ownFilterRows, so it does not protect theCondition. (See the existing TODO atFilterRows:163about cloning the condition intodata;evaluate()already mutatesleftFieldIndex/rightFieldIndex/rightValueon that shared object.) A duplicate build is harmless since both instances are equivalent; a half-published one would not be.@Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE)keeps Lombok off the cache field, so internal cache state is not published as public API.The value data is built lazily rather than alongside the metadata.
evaluate()only needs it when the condition has no right field, so building it eagerly could surface a conversion error on a path that previously never ran. Its flag is stored after the value, so any thread observing the flag also observes the value.No change to generated pipelines or to on-disk metadata, so there is no routing or serialization risk.
Testing Done
Build and unit tests —
hop-core, JDK 17:mvn -pl core -am compileConditionTestValueMetaBaseTest+ValueMetaBaseLenientStringConversionTesthop-coresuitemvn -pl core spotless:checkCValuepublic API (javap)Added coverage.
ConditionTestpreviously exercised the constant-right-value path only once, and never across multiple rows — which is exactly what this change affects. Five tests were added. Only the first guards the performance contract; the rest guard correctness and the UI contract:testEvaluateReusesTheRightValueAcrossRows— a leftIValueMetasubclass captures whatevaluate()passes intocompare(data1, meta2, data2)across two rows, assertingassertSameon both right-side instances. This is the test that stops the regression silently returning: withevaluate()reverted tocreate*per row it fails, while all four other new tests still pass.testConstantRightValueStaysCorrectAcrossRows— evaluates one constant-valued condition against four different rows, so a stale or shared value producing a wrong answer would fail.testConstantRightValueIsBuiltOnlyOnce—assertSameon repeated accessor calls, plusassertNotSameagainstcreateValueMeta()to hold the contract that UI callers still get fresh objects. Note this guards the accessors, notevaluate(). Uses10000Lrather than a small value soLongautoboxing cannot makeassertSamepass spuriously.testConstantRightValueIsRebuiltAfterMutation— mutates throughsetTextandsetNullValueand asserts the evaluation result changes, then asserts per field that each of the seven valuesdescribes()compares (name,type,text,length,precision,nullValue,mask) replaces the cached instance on its own. The per-field checks assert instance identity rather than outcome, because changingname,length,precisionormaskdoes not alter the comparison result. Verified by dropping fields fromdescribes()one at a time — each failure names the culprit. Verified to be a real guard rather than one that passes by construction: withdescribes()temporarily forced to returntrue, it fails.testCachedRightValueTolerateConcurrentColdAccess— 8 threads released onto a genuinely cold cache via a two-latch barrier (workers signal arrival; the gate only opens once all have parked, since pool threads start lazily), 500 iterations each, withFuture.get()rethrowing anything raised on a worker. Covers concurrent first initialisation and the lazyvalueDataBuiltpath under contention. It deliberately drives the cache accessors rather thanevaluate(), which writesleftFieldIndex,rightFieldIndex,rightValue,inListandrightStringon the shared instance and so is not thread safe regardless of caching — see Notes to Reviewers.Still outstanding — end-to-end performance has not yet been measured with this build. The root cause and the trigger are both confirmed by measurement (see Evaluation), but validating the fix itself needs a published Hop artifact for
dms-core-gateto consume. Plan:masking-mavenunder a distinct version (e.g.2.15.0-dlpx1) rather than re-publishing2.15.0, so the before/after is unambiguous. Note the full reactor is required, includinghop-client-<version>.zip, whichHopAdaptorconsumes.hopVersionindms-core-gate/gradle.propertiesand re-run the 1M job.Re-route Header and Trailershould fall from 46.75s CPU / 96% RUNNABLE to near-idle, and the algorithm transform should reach ~95% RUNNABLE, matching 1.2.0's steady state.Notes to Reviewers
Why not fix
ValueMetaBaseinstead? That was the first candidate — hoisting the twoSystem.getPropertyreads out of the constructor into statics would have removed the cost for every caller, not justCondition. It is not viable: four test classes plus a JUnit rule set those properties and expect the next constructedValueMetato observe them —ValueMetaBaseLenientStringConversionTest(toggles Y/N across 4 tests),ValueMetaBaseTest:319,337(N then Y within one test),EditRowsDialog_EmptyStringVsNull_Test, andRestoreHopEnvironment, which clears both between tests. Static caching would freeze the value at class-load and silently break all of them. FixingConditionaddresses the actual regression and leavesValueMetaBasesemantics untouched, which is why its 71 tests still pass.Upstream. This is an upstream Hop regression, not Delphix-specific: any Hop user with a constant-valued
FilterRowscondition pays it, and theSecurityManageronly amplifies an already-wasteful per-row allocation. Worth reporting to Apache Hop so the fork can eventually drop the patch.Jira ID in the code comment follows the convention of the existing Delphix patches in this fork (
DLPX-46230 / DLPX-82789inValueMetaBase,DLPX-51415in the Sybase block) — it tells whoever next forward-ports the patch why the hunk exists.evaluate()is already not thread safe, independent of this change. It writes five shared, non-volatile fields on the shared instance:leftFieldIndex(:336),rightFieldIndex(:342),rightValue = null(:345, destructive),inList(:420) andrightString(:451).FilterRows.keepRowissynchronizedon its ownFilterRowsinstance, which serialises rows within a copy but not across copies, so aFilterRowswith copies > 1 already races on those fields — see the existing TODO atFilterRows:163. That is why the new concurrency test drives the cache accessors rather thanevaluate(): a test over fullevaluate()would be exercising that pre-existing race and would fail or flake for reasons unrelated to this PR. Looks worth its own issue as a latent correctness bug; happy to file it.Limits of the concurrency test. It cannot prove the absence of a race, and an unsafe publication would very likely still pass on x86. The safety argument rests on the cache field being
volatileand the lazy flag being written after the value; the test covers concurrent first initialisation, the lazy path under contention, and gross errors such as returning a holder before its value is set.Follow-up, not in scope here. The masking engine also generates a
FilterRowsper record type for multi-record-type files, using functionREGEXP. That path is worse per row, becausePattern.matchesrecompiles the pattern on every call in addition to building the metadata. This change fixes the metadata half of it; the recompilation is a separate issue.