KKcorps opened a new pull request, #19115:
URL: https://github.com/apache/pinot/pull/19115

   ## TL;DR
   
   A partial upsert table with `postPartialUpsertTransformConfigs` can write 
wrong values into its
   derived columns. `PartialUpsertHandler` was built once per table and shared 
by every partition, but
   it holds record transformers that keep reusable evaluation state, and each 
partition merges on its
   own consumer thread. This PR moves handler creation down to the partition, 
so each partition owns
   its own handler.
   
   ## The problem
   
   `BaseTableUpsertMetadataManager.init()` built **one** `PartialUpsertHandler` 
and put it in the
   shared `UpsertContext`. Every `BasePartitionUpsertMetadataManager` read that 
same instance. Each
   partition ingests on its own consumer thread, so N partitions on a server 
meant N threads calling
   `PartialUpsertHandler.merge()` at the same time.
   
   `merge()` ends by running the post partial upsert transform chain. Those 
transformers are not thread
   safe. `ExpressionTransformer` holds `FunctionEvaluator` instances, and 
`InbuiltFunctionEvaluator`'s
   `FunctionExecutionNode` keeps a reusable `Object[] _arguments` array that it 
refills on every
   `execute()` call:
   
   ```java
   for (int i = 0; i < _argumentNodes.length; i++) {
     _arguments[i] = _argumentNodes[i].execute(row);   // shared array, 
refilled per row
   }
   ...
   return _functionInvoker.invoke(_arguments);
   ```
   
   Two threads writing into that one array interleave. Thread A can fill in its 
argument, get
   descheduled, and then invoke the function after thread B has overwritten the 
same slot. The derived
   column for partition 0's row is then computed from partition 1's values. 
Nothing throws. The row is
   just wrong, and it gets persisted.
   
   ```mermaid
   sequenceDiagram
     participant P0 as Partition 0 consumer
     participant P1 as Partition 1 consumer
     participant N as FunctionExecutionNode<br/>(one shared _arguments[])
     P0->>N: _arguments[0] = 100 (row A)
     P1->>N: _arguments[0] = 999 (row B)
     P0->>N: invoke(_arguments)
     Note over P0,N: ❌ row A's derived column computed from 999
     P1->>N: invoke(_arguments)
   ```
   
   The root cause is ownership granularity. The handler was owned by the table, 
but the mutable state
   inside it can only be owned by one merging thread.
   
   ## The approach
   
   Give each partition its own handler, which is the level the merge path 
already works at.
   
   1. `UpsertContext` now carries a `Supplier<PartialUpsertHandler>` instead of 
a built instance.
      `BaseTableUpsertMetadataManager` sets the supplier when the mode is 
`PARTIAL`.
   2. Each `BasePartitionUpsertMetadataManager` calls the supplier once in its 
constructor and keeps
      the result. One handler per partition, built when the partition manager 
is built.
   3. `getUpsertMode()` and `isTableTypeInconsistentDuringConsumption()` now 
branch on whether the
      supplier is set, which is exactly the signal the old handler-nullness 
check gave them.
   
   ```mermaid
   flowchart LR
     subgraph Before["❌ Before"]
       T[table manager] --> H[one handler]
       H --> P0[Partition 0 thread]
       H --> P1[Partition 1 thread]
     end
     subgraph After["✅ After"]
       T2[table manager] --> S[handler supplier]
       S --> H0[handler for P0] --> P2[Partition 0 thread]
       S --> H1[handler for P1] --> P3[Partition 1 thread]
     end
   ```
   
   ### Why per-partition is enough
   
   `merge()` is only reachable from `doUpdateRecord()`, which already uses 
per-partition unsynchronized
   scratch state right next to the merge call:
   
   ```java
   _reusePreviousRow.init(currentSegment, currentDocId);
   _partialUpsertHandler.merge(_reusePreviousRow, record, 
_reuseMergeResultHolder);
   ```
   
   `_reusePreviousRow` and `_reuseMergeResultHolder` are plain fields on the 
partition manager. Partial
   upsert already depends on at most one thread running `doUpdateRecord()` per 
partition at a time.
   Consumption within a partition is serialized across segments, since a 
segment stops consuming before
   the next one starts. So putting the handler at the partition level relies on 
an invariant the code
   already relies on, rather than adding a new one. I have documented that 
invariant on the classes
   involved.
   
   A `ThreadLocal` inside `PartialUpsertHandler` would also fix the race, but 
it papers over the
   ownership problem instead of fixing it, and leaves per-thread chains alive 
for as long as the thread
   lives. Building the handler where its mutable state belongs is the smaller 
idea.
   
   ## Changes
   
   | File | Change |
   |---|---|
   | `UpsertContext.java` | Carries `Supplier<PartialUpsertHandler>` instead of 
a shared instance; `getPartialUpsertHandler()` → 
`getPartialUpsertHandlerSupplier()`, `setPartialUpsertHandler()` → 
`setPartialUpsertHandlerSupplier()` |
   | `BaseTableUpsertMetadataManager.java` | Builds a supplier instead of one 
handler |
   | `BasePartitionUpsertMetadataManager.java` | Calls the supplier to build 
the partition's own handler |
   | `PartialUpsertHandler.java` | Javadoc: not thread safe, one instance per 
partition |
   | `ExpressionTransformer.java` | Javadoc: not thread safe |
   | `InbuiltFunctionEvaluator.java` | Javadoc: not thread safe, function nodes 
reuse one argument array |
   
   No config, metric, REST, or wire format changes. Nothing is serialized or 
sent across nodes, so
   there is no mixed-version concern.
   
   `UpsertContext` is an internal class in `pinot-segment-local`. The two 
renamed methods had one
   production caller each, both inside this module. Anyone maintaining an 
out-of-tree
   `TableUpsertMetadataManager` that reads 
`UpsertContext.getPartialUpsertHandler()` would need the
   one-line rename, so this is worth a `backward-incompat` glance even though 
nothing in-tree breaks.
   
   ## Performance considerations
   
   - One handler and transform chain per partition instead of per table. 
Construction parses the
     transform expressions, so this is real work, but it happens once when the 
partition manager is
     built and never on the ingestion path.
   - Memory grows with the number of partitions the server hosts for the table. 
Each chain is small.
   - The steady-state merge path is unchanged. The chain is a plain field 
again, so there is no
     `ThreadLocal` lookup per merge.
   
   ## Testing
   
   - `PartialUpsertHandlerTest`, 
`ConcurrentMapPartitionUpsertMetadataManagerTest`,
     `ConcurrentMapPartitionUpsertMetadataManagerForConsistentDeletesTest`,
     `BasePartitionUpsertMetadataManagerTest` and `ExpressionTransformerTest` 
all pass (80 tests).
   - `ConcurrentMapPartitionUpsertMetadataManagerTest` has three tests that 
inject a mock handler
     through the context. They now pass `() -> mockHandler`. That is the only 
test change, and it is a
     mechanical rename, not new coverage.
   - **No new test in this PR.** A regression test for the original race needs 
many threads hammering a
     shared handler and asserting no cross-thread value leaks, which is timing 
sensitive. I would rather
     land the fix than gate it on a test that could turn flaky in CI. Happy to 
add one if reviewers
     want it here.
   
   ## Labels
   
   `bugfix`
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to