bhavya-ganatra opened a new issue, #19570:
URL: https://github.com/apache/hudi/issues/19570

   ### Bug Description
   
   **What happened:**
   
   Under concurrent multi-table writes from a single Spark driver JVM with 
`hoodie.metrics.on=true`, a `NullPointerException` was thrown from inside 
`LocalRegistry.add()` while Hudi was serializing the completed deltacommit 
metadata to S3. The exception surfaced inside `DataFileWriter.close()`, after 
the Avro block header had been flushed to the output stream but before the 
record payload was written. `HoodieStorage.createImmutableFileInPath` closed 
the stream while unwinding, so object storage received a **truncated but 
non-empty** object at the completed-instant filename.
   
   The result is a permanently broken table: 
`<instant>_<completion>.deltacommit` exists and looks like a valid completed 
instant, but cannot be deserialized. Every subsequent write, compaction, clean, 
and Spark read of that table fails, because 
`ActiveTimelineV2.getLastCommitMetadataWithValidSchema()` throws on an 
unreadable completed instant rather than skipping it.
   
   Three separate problems compose here:
   
   1. **`LocalRegistry` is not thread-safe.** `getCounter()` is `synchronized` 
and does `containsKey` -> `put` -> `get`, but `clear()` is **not** synchronized 
(a bare `counters.clear()`). A `clear()` landing between the `containsKey` and 
the `get` makes `get()` return `null`, so `getCounter(name).add(value)` NPEs. 
In steady state the counter already exists, so the exposed window is 
`containsKey -> get`, i.e. every `add()` call, not only first insertion.
   
   2. **`clear()` is called on a JVM-global registry on every `save()`.** 
`HoodieSparkSqlWriter.cleanup()` calls `Metrics.shutdownAllMetrics()`, and 
`DefaultSource.createRelation` invokes `cleanup()` on both the success path and 
inside an exception handler that rethrows (effectively `try/finally`), so it 
runs on every `save()`. `Metrics.shutdown()` -> `registerHoodieCommonMetrics()` 
-> `Registry.getAllMetrics(flush=true, ...)` -> `clear()` on every registry in 
the global `REGISTRY_MAP`, which includes the JVM-static 
`HoodieWrapperFileSystem.METRICS_REGISTRY_DATA/_META` that all tables write 
into via `SizeAwareFSDataOutputStream.write()`. `shutdown()` also removes the 
instance from `METRICS_INSTANCE_PER_BASEPATH`, so the next batch's `Metrics` 
constructor re-registers and clears again.
   
      Net effect: at least two global registry wipes per table per micro-batch. 
With ~30 tables written concurrently from one driver, that is ~60 wipes per 
batch racing against continuous `add()` calls from all writer threads.
   
   3. **A failure during commit-metadata serialization leaves a partial object 
at an authoritative filename.** `CommitMetadataSerDe.getInstantWriter` streams 
Avro directly into the storage output stream, so any exception 
mid-serialization can produce a file that Hudi treats as a completed instant.
   
   **What you expected:**
   
   - `LocalRegistry.getCounter()` never returns `null`, so a metrics operation 
cannot fail a write.
   - One table's `save()` does not tear down metrics state belonging to other 
tables in the same JVM.
   - A failure while writing commit metadata leaves either a complete file or 
no file at all — never a partial object at a completed-instant filename.
   
   **Steps to reproduce:**
   
   1. Set `hoodie.metrics.on=true` (any reporter type).
   2. From a single Spark driver JVM, write to N Hudi tables concurrently on 
separate threads (N ~= 30 in our case), each via 
`df.write.format("hudi")...save(path)`, in a loop (we used Structured Streaming 
micro-batches, but any repeated concurrent write should work).
   3. Let it run. Each `save()` completion triggers 
`Metrics.shutdownAllMetrics()`, clearing the JVM-static FS metrics registry 
while other threads are still calling `Registry.add(...)` on it from their 
write paths.
   4. Eventually `LocalRegistry.getCounter()` returns `null` and the NPE lands 
inside a commit-metadata write, leaving a truncated `.deltacommit`. In our 
environment this happened roughly 3 times over 2 weeks per table set — it is a 
narrow race, so reproduction is probabilistic.
   
   
   
   
   ### Environment
   
   **Hudi version:** 1.1.0 (`hudi-spark3.5-bundle_2.12-1.1.0`, 
`hudi-aws-bundle-1.1.0`)
   **Query engine:** Spark 3.5.6, Scala 2.12. Table type MERGE_ON_READ, table 
version 9, timeline layout version 2, non-partitioned. Storage: S3 (S3A).
   **Relevant configs:** 
   
   ```
   hoodie.metrics.on=true
   hoodie.metrics.reporter.type=PROMETHEUS_PUSHGATEWAY
   hoodie.metrics.executor.enable=false          # so the driver-side registry 
is LocalRegistry
   hoodie.datasource.write.table.type=MERGE_ON_READ
   hoodie.metadata.enable=true
   hoodie.write.concurrency.mode=optimistic_concurrency_control
   hoodie.clean.automatic=false                  # clean/compaction run as 
separate jobs
   ```
   
   Key environmental condition: **many Hudi write clients active concurrently 
inside one driver JVM.** A single-writer driver will not hit this.
   
   ### Logs and Stack Trace
   
   ## 1. The originating NPE (application package names genericized)
   
   ```
   ERROR [pool-3-thread-3464] Error writing to Hudi: Cannot invoke
     "org.apache.hudi.common.metrics.Counter.add(long)" because the return 
value of
     "org.apache.hudi.common.metrics.LocalRegistry.getCounter(String)" is null
   java.lang.NullPointerException: Cannot invoke 
"org.apache.hudi.common.metrics.Counter.add(long)"
     because the return value of 
"org.apache.hudi.common.metrics.LocalRegistry.getCounter(String)" is null
        at 
org.apache.hudi.common.metrics.LocalRegistry.add(LocalRegistry.java:48) 
~[hudi-aws-bundle-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.hadoop.fs.HoodieWrapperFileSystem.executeFuncWithTimeAndByteMetrics(HoodieWrapperFileSystem.java:133)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.hadoop.fs.SizeAwareFSDataOutputStream.write(SizeAwareFSDataOutputStream.java:59)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.avro.file.DataFileWriter$BufferedFileOutputStream$PositionFilter.write(DataFileWriter.java:485)
 ~[avro-1.11.4.jar:1.11.4]
        at java.io.BufferedOutputStream.write(BufferedOutputStream.java:123) 
~[?:?]
        at 
org.apache.avro.io.DirectBinaryEncoder.writeFixed(DirectBinaryEncoder.java:124) 
~[avro-1.11.4.jar:1.11.4]
        at 
org.apache.avro.file.DataFileStream$DataBlock.writeBlockTo(DataFileStream.java:407)
 ~[avro-1.11.4.jar:1.11.4]
        at 
org.apache.avro.file.DataFileWriter.writeBlock(DataFileWriter.java:417) 
~[avro-1.11.4.jar:1.11.4]
        at org.apache.avro.file.DataFileWriter.sync(DataFileWriter.java:437) 
~[avro-1.11.4.jar:1.11.4]
        at org.apache.avro.file.DataFileWriter.flush(DataFileWriter.java:446) 
~[avro-1.11.4.jar:1.11.4]
        at org.apache.avro.file.DataFileWriter.close(DataFileWriter.java:469) 
~[avro-1.11.4.jar:1.11.4]
        at 
org.apache.hudi.common.table.timeline.CommitMetadataSerDe.lambda$getInstantWriter$0(CommitMetadataSerDe.java:52)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.storage.HoodieStorage.createImmutableFileInPath(HoodieStorage.java:349)
 ~[hudi-aws-bundle-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.storage.HoodieStorage.createImmutableFileInPath(HoodieStorage.java:312)
 ~[hudi-aws-bundle-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2.lambda$createCompleteFileInMetaPath$6(ActiveTimelineV2.java:758)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.common.table.timeline.SkewAdjustingTimeGenerator.consumeTime(SkewAdjustingTimeGenerator.java:63)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2.createCompleteFileInMetaPath(ActiveTimelineV2.java:751)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2.transitionStateToComplete(ActiveTimelineV2.java:566)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2.saveAsComplete(ActiveTimelineV2.java:171)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.client.BaseHoodieWriteClient.commit(BaseHoodieWriteClient.java:321)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.client.BaseHoodieWriteClient.commitStats(BaseHoodieWriteClient.java:276)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.client.SparkRDDWriteClient.commit(SparkRDDWriteClient.java:149) 
~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.HoodieSparkSqlWriterInternal.commitAndPerformPostOperations(HoodieSparkSqlWriter.scala:989)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.HoodieSparkSqlWriterInternal.writeInternal(HoodieSparkSqlWriter.scala:548)
 ~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.HoodieSparkSqlWriter$.write(HoodieSparkSqlWriter.scala:133) 
~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.hudi.DefaultSource.createRelation(DefaultSource.scala:171) 
~[hudi-spark3.5-bundle_2.12-1.1.0.jar:1.1.0]
        at 
org.apache.spark.sql.execution.datasources.SaveIntoDataSourceCommand.run(SaveIntoDataSourceCommand.scala:48)
 ~[spark-sql_2.12-3.5.6.jar:3.5.6]
        ... Spark plumbing elided ...
        at org.apache.spark.sql.DataFrameWriter.save(DataFrameWriter.scala:244) 
~[spark-sql_2.12-3.5.6.jar:3.5.6]
        at 
com.example.writer.HudiDataWriter.executeHudiWrite(HudiDataWriter.java:211) 
~[app.jar:?]
        at 
com.example.writer.ParallelGroupProcessor.lambda$processGroups$0(ParallelGroupProcessor.java:127)
 ~[app.jar:?]
        at 
java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1804)
 [?:?]
        at 
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136) 
[?:?]
        at java.lang.Thread.run(Thread.java:840) [?:?]
   ```
   
   ## 2. Resulting file corruption
   
   The completed deltacommit written by that failed commit, parsed as an Avro 
object container:
   
   ```
   <instant>_<completion>.deltacommit          size = 3333 bytes    <-- corrupt
      avro.schema metadata: 3293 bytes; header ends at byte 3329
      block 0: records=1  declared_payload=13004  bytes_present=0
      *** TRUNCATED: expected total size 16353, actual 3333 ***
   
   previous healthy deltacommit                 size = 17212 bytes
      block 0: records=1  declared_payload=13863  bytes_present=13879   OK
   ```
   
   The file contains the complete Avro header plus exactly the 4-byte block 
header (record count = 1, payload length = 13004) and zero payload bytes. That 
is precisely the boundary inside `DataFileWriter.writeBlock()` between 
`vout.flush()` (which emits those 4 bytes) and `buffer.writeTo(out)` (the 
payload). The S3 ETag has no multipart suffix and there is a single object 
version, so this was one PUT of a truncated body — not a storage-layer fault.
   
   Corroborating state after the failure:
   - The metadata table's deltacommit for the same instant completed 
successfully ~558 ms earlier and is valid.
   - All 37 log files for the instant were written intact.
   - The marker directory `.hoodie/.temp/<instant>/` was never cleaned, 
confirming commit finalization never ran.
   
   ## 3. Consequent permanent failure on every later operation
   
   ```
   org.apache.hudi.exception.HoodieIOException: Failed to fetch 
HoodieCommitMetadata for instant
     ([<instant>__<completion>__deltacommit__COMPLETED])
        at 
org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2.lambda$getCommitMetadataStream$4(ActiveTimelineV2.java:322)
        at 
org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2.getLastCommitMetadataWithValidSchema(ActiveTimelineV2.java:299)
        at 
org.apache.hudi.common.table.TableSchemaResolver.getLatestCommitMetadataWithValidSchema(TableSchemaResolver.java:414)
        at 
org.apache.hudi.common.table.TableSchemaResolver.getTableSchemaFromLatestCommitMetadata(TableSchemaResolver.java:211)
        at 
org.apache.hudi.common.table.TableSchemaResolver.getTableAvroSchemaFromLatestCommit(TableSchemaResolver.java:286)
        at 
org.apache.hudi.HoodieSparkSqlWriterInternal.getLatestTableSchema(HoodieSparkSqlWriter.scala:679)
        at 
org.apache.hudi.HoodieSparkSqlWriterInternal.writeInternal(HoodieSparkSqlWriter.scala:361)
        ...
   Caused by: java.io.IOException: unable to read commit metadata for instant 
[...]
        at 
org.apache.hudi.common.table.timeline.versioning.v2.CommitMetadataSerDeV2.deserialize(CommitMetadataSerDeV2.java:88)
   Caused by: java.lang.IllegalArgumentException: Could not deserialize 
metadata of type
     class org.apache.hudi.avro.model.HoodieCommitMetadata
        at 
org.apache.hudi.common.util.ValidationUtils.checkArgument(ValidationUtils.java:42)
        at 
org.apache.hudi.common.table.timeline.TimelineMetadataUtils.deserializeAvroMetadata(TimelineMetadataUtils.java:139)
   ```
   
   Because `getCommitMetadataStream()` builds from 
`getCommitsTimeline().filterCompletedInstants()` and the lambda throws rather 
than skipping, the poisoned instant blocks schema resolution for every caller 
of `TableSchemaResolver` — writers, the compactor, the cleaner, and Spark 
reads. The exception is not an empty result, so the existing fallbacks to the 
table-config schema and to the latest data file are never reached.
   
   
   # Few Suggested fixes(Based on suggestions from Claude Opus Model):
   These are independent and have different severities.
   
   **1. Make `LocalRegistry.getCounter()` incapable of returning null.** 
Smallest, clearly correct fix:
   
   ```java
   private Counter getCounter(String name) {
       return counters.computeIfAbsent(name, k -> new Counter());
   }
   ```
   
   `computeIfAbsent` on the existing `ConcurrentHashMap` cannot return null, so 
a racing `clear()` costs a few counts instead of failing a commit. It also 
removes the `synchronized` on the hot path, which every FS write in the JVM 
currently contends on. (Alternatively, synchronize `clear()` on the same 
monitor — but `computeIfAbsent` is both correct and faster.)
   
   **2. Stop `HoodieSparkSqlWriter.cleanup()` from tearing down global metrics 
state.** One table's `save()` calling `Metrics.shutdownAllMetrics()` destroys 
metrics for every other table in the JVM. This is wrong independent of the race 
— in a multi-table driver it also means per-table metrics are continuously 
clobbered and re-registered. Scoping the shutdown to the base path being 
written (or not shutting down at all on a normal `save()`) would fix both.
   
   **3. Never leave a partial commit-metadata object at a completed-instant 
path.** This is the highest-severity item, because it converts any transient 
exception in that code path into permanent table corruption that no retry can 
clear. `CommitMetadataSerDe.getInstantWriter` streams Avro straight into the 
storage output stream, so a mid-serialization failure produces a file Hudi 
subsequently treats as authoritative. Options:
   
   - Serialize to a byte array and issue a single write. Commit metadata is 
small (13 KB here); for tables with very large partition counts this trades 
memory, which is presumably why streaming was chosen, so it may need to be 
conditional.
   - Or write to a temporary path and only move/copy to the final name after a 
successful close.
   - Or verify the written object's length against the expected byte count 
immediately after close, and delete plus fail if it does not match.
   
   Fixing (1) removes today's trigger. Fixing (3) removes the whole failure 
class, including future triggers.
   
   **4. Optional hardening:** consider making 
`getLastCommitMetadataWithValidSchema()` skip unreadable instants rather than 
throwing, or at least surfacing a message that names the file and states that 
the table is unreadable until the instant is rolled back. Today the error gives 
no indication that the fix is to roll back the newest instant.
   
   
   For now, as a mitigation step, we had deleted corrupted .delatcommit instant 
from timeline, deleted metadata table and reconstructed it. And, disabled hudi 
metrics `hoodie.metrics.on=false`. We had tried rollback but it didn't work as 
it required metadata table. And, since metadata table was inconsistent, we had 
only option to delete instant from timeline, and reconstruct metadata table. 


-- 
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]

Reply via email to