This is an automated email from the ASF dual-hosted git repository.

stankiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 4fbc6a12113 Fix nullness in extensions/avro (#39976)
4fbc6a12113 is described below

commit 4fbc6a121135e25303edea2b690cd3d6c116a615
Author: Kenneth Knowles <[email protected]>
AuthorDate: Fri Sep 25 06:01:34 2026 -0400

    Fix nullness in extensions/avro (#39976)
---
 .../beam/sdk/extensions/avro/coders/AvroCoder.java |  45 ++++---
 .../sdk/extensions/avro/io/AvroDatumFactory.java   |  12 +-
 .../apache/beam/sdk/extensions/avro/io/AvroIO.java | 133 ++++++++++++---------
 .../extensions/avro/io/AvroSchemaIOProvider.java   |  18 ++-
 .../beam/sdk/extensions/avro/io/AvroSink.java      |  26 ++--
 .../beam/sdk/extensions/avro/io/AvroSource.java    |  87 +++++++++-----
 .../avro/io/ConstantAvroDestination.java           |   5 +-
 .../avro/io/SerializableAvroCodecFactory.java      |  24 ++--
 .../avro/schemas/utils/AvroByteBuddyUtils.java     |   5 +-
 .../extensions/avro/schemas/utils/AvroUtils.java   |  37 +++---
 .../avro/schemas/utils/AvroUtilsTest.java          |  83 ++++++++++++-
 11 files changed, 308 insertions(+), 167 deletions(-)

diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/coders/AvroCoder.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/coders/AvroCoder.java
index f8cc1a4074c..f630e741387 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/coders/AvroCoder.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/coders/AvroCoder.java
@@ -17,6 +17,8 @@
  */
 package org.apache.beam.sdk.extensions.avro.coders;
 
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.errorprone.annotations.FormatMethod;
 import com.google.errorprone.annotations.FormatString;
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
@@ -109,9 +111,6 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  *
  * @param <T> the type of elements handled by this coder
  */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public class AvroCoder<T> extends CustomCoder<T> {
 
   private static final Cache<AvroCoderCacheKey, AvroCoder<?>> AVRO_CODER_CACHE 
=
@@ -137,7 +136,12 @@ public class AvroCoder<T> extends CustomCoder<T> {
    * suite for encoding and decoding.
    */
   public static <T> AvroCoder<T> specific(Class<T> type) {
-    return specific(type, new 
SpecificData(type.getClassLoader()).getSchema(type));
+    return specific(type, specificSchemaOf(type));
+  }
+
+  @SuppressWarnings("nullness") // SpecificData tolerates a null class loader 
but is unannotated
+  private static Schema specificSchemaOf(Class<?> type) {
+    return new SpecificData(type.getClassLoader()).getSchema(type);
   }
 
   /**
@@ -167,7 +171,12 @@ public class AvroCoder<T> extends CustomCoder<T> {
    * suite for encoding and decoding.
    */
   public static <T> AvroCoder<T> reflect(Class<T> type) {
-    return reflect(type, new 
ReflectData(type.getClassLoader()).getSchema(type));
+    return reflect(type, reflectSchemaOf(type));
+  }
+
+  @SuppressWarnings("nullness") // ReflectData tolerates a null class loader 
but is unannotated
+  private static Schema reflectSchemaOf(Class<?> type) {
+    return new ReflectData(type.getClassLoader()).getSchema(type);
   }
 
   /**
@@ -395,10 +404,10 @@ public class AvroCoder<T> extends CustomCoder<T> {
 
   // writer and reader are unused but kept for serialization update 
compatibility.
   @SuppressWarnings("unused")
-  private final EmptyOnDeserializationThreadLocal<DatumWriter<T>> writer = 
null;
+  private final @Nullable EmptyOnDeserializationThreadLocal<DatumWriter<T>> 
writer = null;
 
   @SuppressWarnings("unused")
-  private final EmptyOnDeserializationThreadLocal<DatumReader<T>> reader = 
null;
+  private final @Nullable EmptyOnDeserializationThreadLocal<DatumReader<T>> 
reader = null;
 
   // datumReader and datumWriter are initialized in the constructor and
   // on deserialization (see readObject).
@@ -424,7 +433,8 @@ public class AvroCoder<T> extends CustomCoder<T> {
     this.decoder = new EmptyOnDeserializationThreadLocal<>();
     this.encoder = new EmptyOnDeserializationThreadLocal<>();
 
-    initializeAvroDatumReaderAndWriter();
+    this.datumReader = datumFactory.apply(schema, schema);
+    this.datumWriter = datumFactory.apply(schema);
   }
 
   /** Returns the type this coder encodes/decodes. */
@@ -473,6 +483,11 @@ public class AvroCoder<T> extends CustomCoder<T> {
     BinaryDecoder decoderInstance = 
DECODER_FACTORY.directBinaryDecoder(inStream, decoder.get());
     // Save the potentially-new instance for later.
     decoder.set(decoderInstance);
+    return readWithoutReuse(decoderInstance);
+  }
+
+  @SuppressWarnings("nullness") // DatumReader.read accepts a null reuse but 
is unannotated
+  private T readWithoutReuse(BinaryDecoder decoderInstance) throws IOException 
{
     return datumReader.read(null, decoderInstance);
   }
 
@@ -808,10 +823,10 @@ public class AvroCoder<T> extends CustomCoder<T> {
     }
 
     private void checkArray(String context, TypeDescriptor<?> type, Schema 
schema) {
-      TypeDescriptor<?> elementType = null;
+      TypeDescriptor<?> elementType;
       if (type.isArray()) {
         // The type is an array (with ordering)-> deterministic iff the 
element is deterministic.
-        elementType = type.getComponentType();
+        elementType = checkNotNull(type.getComponentType());
       } else if (isSubtypeOf(type, Collection.class)) {
         if (isSubtypeOf(type, List.class, SortedSet.class)) {
           // Ordered collection -> deterministic iff the element is 
deterministic
@@ -895,16 +910,12 @@ public class AvroCoder<T> extends CustomCoder<T> {
       this.datumReader = cachedCoder.get().datumReader;
       this.datumWriter = cachedCoder.get().datumWriter;
     } else {
-      initializeAvroDatumReaderAndWriter();
+      Schema schema = this.schemaSupplier.get();
+      this.datumReader = this.datumFactory.apply(schema, schema);
+      this.datumWriter = this.datumFactory.apply(schema);
     }
   }
 
-  private void initializeAvroDatumReaderAndWriter() {
-    this.datumReader =
-        this.datumFactory.apply(this.schemaSupplier.get(), 
this.schemaSupplier.get());
-    this.datumWriter = this.datumFactory.apply(this.schemaSupplier.get());
-  }
-
   enum AvroCoderType {
     SPECIFIC,
     REFLECT;
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroDatumFactory.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroDatumFactory.java
index 7d2fd43d7ab..1f010fa4a9b 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroDatumFactory.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroDatumFactory.java
@@ -34,9 +34,6 @@ import 
org.apache.beam.sdk.extensions.avro.schemas.utils.AvroUtils;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** Create {@link DatumReader} and {@link DatumWriter} for given schemas. */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public abstract class AvroDatumFactory<T>
     implements AvroSource.DatumReaderFactory<T>, 
AvroSink.DatumWriterFactory<T> {
 
@@ -172,14 +169,14 @@ public abstract class AvroDatumFactory<T>
 
     @Override
     public DatumReader<T> apply(Schema writer, Schema reader) {
-      ReflectData data = new ReflectData(type.getClassLoader());
+      ReflectData data = newReflectData(type);
       AvroUtils.addLogicalTypeConversions(data);
       return new ReflectDatumReader<>(writer, reader, data);
     }
 
     @Override
     public DatumWriter<T> apply(Schema writer) {
-      ReflectData data = new ReflectData(type.getClassLoader());
+      ReflectData data = newReflectData(type);
       AvroUtils.addLogicalTypeConversions(data);
       return new ReflectDatumWriter<>(writer, data);
     }
@@ -187,5 +184,10 @@ public abstract class AvroDatumFactory<T>
     public static <T> ReflectDatumFactory<T> of(Class<T> type) {
       return new ReflectDatumFactory<>(type);
     }
+
+    @SuppressWarnings("nullness") // ReflectData tolerates a null class loader 
but is unannotated
+    private static ReflectData newReflectData(Class<?> type) {
+      return new ReflectData(type.getClassLoader());
+    }
   }
 }
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroIO.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroIO.java
index 6b23695c21a..33b78abad4f 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroIO.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroIO.java
@@ -19,8 +19,8 @@ package org.apache.beam.sdk.extensions.avro.io;
 
 import static org.apache.beam.sdk.io.FileIO.ReadMatches.DirectoryTreatment;
 import static 
org.apache.beam.sdk.io.ReadAllViaFileBasedSource.ReadFileRangesFnExceptionHandler;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
-import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.auto.value.AutoValue;
 import java.io.IOException;
@@ -344,9 +344,6 @@ import org.joda.time.Duration;
  * TypedWrite#withBadRecordErrorHandler(ErrorHandler)}. See documentation in 
{@link FileIO} for
  * details on usage
  */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public class AvroIO {
   /**
    * Reads records of the given type from an Avro file (or multiple Avro files 
matching a pattern).
@@ -619,7 +616,7 @@ public class AvroIO {
   }
 
   private static <T> PCollection<T> setBeamSchema(
-      PCollection<T> pc, Class<T> clazz, @Nullable Schema schema) {
+      PCollection<T> pc, Class<T> clazz, Schema schema) {
     return pc.setCoder(AvroUtils.schemaCoder(clazz, schema));
   }
 
@@ -637,9 +634,9 @@ public class AvroIO {
 
     abstract MatchConfiguration getMatchConfiguration();
 
-    abstract @Nullable Class<T> getRecordClass();
+    abstract Class<T> getRecordClass();
 
-    abstract @Nullable Schema getSchema();
+    abstract Schema getSchema();
 
     abstract boolean getInferBeamSchema();
 
@@ -761,8 +758,7 @@ public class AvroIO {
     @Override
     @SuppressWarnings("unchecked")
     public PCollection<T> expand(PBegin input) {
-      checkNotNull(getFilepattern(), "filepattern");
-      checkNotNull(getSchema(), "schema");
+      ValueProvider<String> filepattern = checkStateNotNull(getFilepattern(), 
"filepattern");
 
       if (getMatchConfiguration().getWatchInterval() == null && 
!getHintMatchesManyFiles()) {
         PCollection<T> read =
@@ -770,7 +766,7 @@ public class AvroIO {
                 "Read",
                 org.apache.beam.sdk.io.Read.from(
                     createSource(
-                        getFilepattern(),
+                        filepattern,
                         getMatchConfiguration().getEmptyMatchTreatment(),
                         getRecordClass(),
                         getSchema(),
@@ -784,15 +780,21 @@ public class AvroIO {
           (getRecordClass() == GenericRecord.class)
               ? (ReadFiles<T>) readFilesGenericRecords(getSchema())
               : readFiles(getRecordClass());
+      AvroSource.DatumReaderFactory<T> readerFactory = getDatumReaderFactory();
+      if (readerFactory != null) {
+        readFiles = readFiles.withDatumReaderFactory(readerFactory);
+      }
+      Coder<T> coder = getCoder();
+      if (coder != null) {
+        readFiles = readFiles.withCoder(coder);
+      }
       return input
-          .apply("Create filepattern", Create.ofProvider(getFilepattern(), 
StringUtf8Coder.of()))
+          .apply("Create filepattern", Create.ofProvider(filepattern, 
StringUtf8Coder.of()))
           .apply("Match All", 
FileIO.matchAll().withConfiguration(getMatchConfiguration()))
           .apply(
               "Read Matches",
               
FileIO.readMatches().withDirectoryTreatment(DirectoryTreatment.PROHIBIT))
-          .apply(
-              "Via ReadFiles",
-              
readFiles.withDatumReaderFactory(getDatumReaderFactory()).withCoder(getCoder()));
+          .apply("Via ReadFiles", readFiles);
     }
 
     @Override
@@ -842,9 +844,9 @@ public class AvroIO {
   public abstract static class ReadFiles<T>
       extends PTransform<PCollection<ReadableFile>, PCollection<T>> {
 
-    abstract @Nullable Class<T> getRecordClass();
+    abstract Class<T> getRecordClass();
 
-    abstract @Nullable Schema getSchema();
+    abstract Schema getSchema();
 
     abstract boolean getUsesReshuffle();
 
@@ -924,7 +926,6 @@ public class AvroIO {
 
     @Override
     public PCollection<T> expand(PCollection<ReadableFile> input) {
-      checkNotNull(getSchema(), "schema");
       Coder<T> coder =
           
Optional.ofNullable(getCoder()).orElse(AvroCoder.of(getRecordClass(), 
getSchema()));
       PCollection<T> read =
@@ -965,9 +966,9 @@ public class AvroIO {
   public abstract static class ReadAll<T> extends 
PTransform<PCollection<String>, PCollection<T>> {
     abstract MatchConfiguration getMatchConfiguration();
 
-    abstract @Nullable Class<T> getRecordClass();
+    abstract Class<T> getRecordClass();
 
-    abstract @Nullable Schema getSchema();
+    abstract Schema getSchema();
 
     abstract long getDesiredBundleSizeBytes();
 
@@ -1025,7 +1026,6 @@ public class AvroIO {
 
     @Override
     public PCollection<T> expand(PCollection<String> input) {
-      checkNotNull(getSchema(), "schema");
       PCollection<T> read =
           input
               
.apply(FileIO.matchAll().withConfiguration(getMatchConfiguration()))
@@ -1052,13 +1052,13 @@ public class AvroIO {
     private final Class<T> recordClass;
     private final Supplier<Schema> schemaSupplier;
     private final Coder<T> coder;
-    private final AvroSource.DatumReaderFactory<T> readerFactory;
+    private final AvroSource.@Nullable DatumReaderFactory<T> readerFactory;
 
     CreateSourceFn(
         Class<T> recordClass,
         String jsonSchema,
         Coder<T> coder,
-        AvroSource.DatumReaderFactory<T> readerFactory) {
+        AvroSource.@Nullable DatumReaderFactory<T> readerFactory) {
       this.recordClass = recordClass;
       this.schemaSupplier =
           Suppliers.memoize(
@@ -1158,18 +1158,18 @@ public class AvroIO {
 
     @Override
     public PCollection<T> expand(PBegin input) {
-      checkNotNull(getFilepattern(), "filepattern");
+      ValueProvider<String> filepattern = checkStateNotNull(getFilepattern(), 
"filepattern");
       Coder<T> coder = inferCoder(getCoder(), getParseFn(), 
input.getPipeline().getCoderRegistry());
 
       if (getMatchConfiguration().getWatchInterval() == null && 
!getHintMatchesManyFiles()) {
         return input.apply(
             org.apache.beam.sdk.io.Read.from(
-                AvroSource.from(getFilepattern()).withParseFn(getParseFn(), 
coder)));
+                AvroSource.from(filepattern).withParseFn(getParseFn(), 
coder)));
       }
 
       // All other cases go through FileIO + ParseFilesGenericRecords.
       return input
-          .apply("Create filepattern", Create.ofProvider(getFilepattern(), 
StringUtf8Coder.of()))
+          .apply("Create filepattern", Create.ofProvider(filepattern, 
StringUtf8Coder.of()))
           .apply("Match All", 
FileIO.matchAll().withConfiguration(getMatchConfiguration()))
           .apply(
               "Read Matches",
@@ -1377,12 +1377,15 @@ public class AvroIO {
 
     @Override
     public PCollection<T> expand(PCollection<String> input) {
+      ParseFiles<T> parseFiles = parseFilesGenericRecords(getParseFn());
+      Coder<T> coder = getCoder();
+      if (coder != null) {
+        parseFiles = parseFiles.withCoder(coder);
+      }
       return input
           .apply(FileIO.matchAll().withConfiguration(getMatchConfiguration()))
           
.apply(FileIO.readMatches().withDirectoryTreatment(DirectoryTreatment.PROHIBIT))
-          .apply(
-              "Parse all via FileBasedSource",
-              parseFilesGenericRecords(getParseFn()).withCoder(getCoder()));
+          .apply("Parse all via FileBasedSource", parseFiles);
     }
 
     @Override
@@ -1404,6 +1407,13 @@ public class AvroIO {
     static final SerializableAvroCodecFactory DEFAULT_SERIALIZABLE_CODEC =
         new SerializableAvroCodecFactory(DEFAULT_CODEC);
 
+    private static final String SCHEMA_REQUIRED =
+        "Unless using DynamicDestinations, .withSchema() is required.";
+    private static final String FORMAT_FUNCTION_REQUIRED =
+        "Unless using DynamicDestinations, .withFormatFunction() is required.";
+    private static final String FILENAME_PREFIX_REQUIRED =
+        "Need to set either the filename prefix or the tempDirectory of a 
AvroIO.Write transform.";
+
     abstract @Nullable SerializableFunction<UserT, OutputT> 
getFormatFunction();
 
     abstract @Nullable ValueProvider<ResourceId> getFilenamePrefix();
@@ -1753,7 +1763,7 @@ public class AvroIO {
         if (usedFilenamePolicy == null) {
           usedFilenamePolicy =
               DefaultFilenamePolicy.fromStandardParameters(
-                  getFilenamePrefix(),
+                  checkStateNotNull(getFilenamePrefix(), 
FILENAME_PREFIX_REQUIRED),
                   getShardTemplate(),
                   getFilenameSuffix(),
                   getWindowedWrites());
@@ -1762,10 +1772,10 @@ public class AvroIO {
             (DynamicAvroDestinations<UserT, DestinationT, OutputT>)
                 constantDestinations(
                     usedFilenamePolicy,
-                    getSchema(),
+                    checkStateNotNull(getSchema(), SCHEMA_REQUIRED),
                     getMetadata(),
                     getCodec().getCodec(),
-                    getFormatFunction(),
+                    checkStateNotNull(getFormatFunction(), 
FORMAT_FUNCTION_REQUIRED),
                     getDatumWriterFactory());
       }
       return dynamicDestinations;
@@ -1774,9 +1784,7 @@ public class AvroIO {
     @Override
     public WriteFilesResult<DestinationT> expand(PCollection<UserT> input) {
       checkArgument(
-          getFilenamePrefix() != null || getTempDirectory() != null,
-          "Need to set either the filename prefix or the tempDirectory of a 
AvroIO.Write "
-              + "transform.");
+          getFilenamePrefix() != null || getTempDirectory() != null, 
FILENAME_PREFIX_REQUIRED);
       if (getFilenamePolicy() != null) {
         checkArgument(
             getShardTemplate() == null && getFilenameSuffix() == null,
@@ -1789,13 +1797,12 @@ public class AvroIO {
             "A format function should not be specified "
                 + "with DynamicDestinations. Use 
DynamicDestinations.formatRecord instead");
       } else {
-        checkArgument(
-            getSchema() != null, "Unless using DynamicDestinations, 
.withSchema() is required.");
+        checkArgument(getSchema() != null, SCHEMA_REQUIRED);
       }
 
       ValueProvider<ResourceId> tempDirectory = getTempDirectory();
       if (tempDirectory == null) {
-        tempDirectory = getFilenamePrefix();
+        tempDirectory = checkStateNotNull(getFilenamePrefix(), 
FILENAME_PREFIX_REQUIRED);
       }
       WriteFiles<UserT, DestinationT, OutputT> write =
           WriteFiles.to(
@@ -1813,11 +1820,13 @@ public class AvroIO {
       if (getNoSpilling()) {
         write = write.withNoSpilling();
       }
-      if (getMaxNumWritersPerBundle() != null) {
-        write = write.withMaxNumWritersPerBundle(getMaxNumWritersPerBundle());
+      Integer maxNumWritersPerBundle = getMaxNumWritersPerBundle();
+      if (maxNumWritersPerBundle != null) {
+        write = write.withMaxNumWritersPerBundle(maxNumWritersPerBundle);
       }
-      if (getBadRecordErrorHandler() != null) {
-        write = write.withBadRecordErrorHandler(getBadRecordErrorHandler());
+      ErrorHandler<BadRecord, ?> badRecordErrorHandler = 
getBadRecordErrorHandler();
+      if (badRecordErrorHandler != null) {
+        write = write.withBadRecordErrorHandler(badRecordErrorHandler);
       }
       return input.apply("Write", write);
     }
@@ -2020,13 +2029,13 @@ public class AvroIO {
 
   private static class FormattedDatumWriter<ElementT> implements 
DatumWriter<ElementT> {
     private Schema root;
-    private RecordFormatter<ElementT> formatter;
-    private GenericDatumWriter<GenericRecord> writer;
+    private final RecordFormatter<ElementT> formatter;
+    private final GenericDatumWriter<GenericRecord> writer;
 
     public FormattedDatumWriter(Schema schema, RecordFormatter<ElementT> 
formatter) {
       this.formatter = formatter;
       this.writer = new GenericDatumWriter<>(schema);
-      setSchema(schema);
+      this.root = schema;
     }
 
     @Override
@@ -2101,7 +2110,7 @@ public class AvroIO {
     @Deprecated
     abstract @Nullable RecordFormatter<ElementT> getRecordFormatter();
 
-    abstract @Nullable String getJsonSchema();
+    abstract String getJsonSchema();
 
     abstract Map<String, Object> getMetadata();
 
@@ -2151,47 +2160,53 @@ public class AvroIO {
       return toBuilder().setDatumWriterFactory(datumWriterFactory).build();
     }
 
-    private transient @Nullable Schema schema;
     private transient @Nullable DataFileWriter<ElementT> writer;
 
     @Override
     public void open(WritableByteChannel channel) throws IOException {
-      this.schema = new Schema.Parser().parse(getJsonSchema());
+      Schema schema = new Schema.Parser().parse(getJsonSchema());
+      RecordFormatter<ElementT> recordFormatter = getRecordFormatter();
+      AvroSink.DatumWriterFactory<ElementT> datumWriterFactory = 
getDatumWriterFactory();
       DatumWriter<ElementT> datumWriter;
-      if (getRecordFormatter() != null) {
-        datumWriter = new FormattedDatumWriter<>(schema, getRecordFormatter());
-      } else if (getDatumWriterFactory() != null) {
-        datumWriter = getDatumWriterFactory().apply(schema);
+      if (recordFormatter != null) {
+        datumWriter = new FormattedDatumWriter<>(schema, recordFormatter);
+      } else if (datumWriterFactory != null) {
+        datumWriter = datumWriterFactory.apply(schema);
       } else {
         datumWriter = new ReflectDatumWriter<>(schema);
       }
-      writer = new DataFileWriter<>(datumWriter);
-      writer.setCodec(getCodec().getCodec());
+      DataFileWriter<ElementT> dataFileWriter = new 
DataFileWriter<>(datumWriter);
+      dataFileWriter.setCodec(getCodec().getCodec());
       for (Map.Entry<String, Object> entry : getMetadata().entrySet()) {
         Object v = entry.getValue();
         if (v instanceof String) {
-          writer.setMeta(entry.getKey(), (String) v);
+          dataFileWriter.setMeta(entry.getKey(), (String) v);
         } else if (v instanceof Long) {
-          writer.setMeta(entry.getKey(), (Long) v);
+          dataFileWriter.setMeta(entry.getKey(), (Long) v);
         } else if (v instanceof byte[]) {
-          writer.setMeta(entry.getKey(), (byte[]) v);
+          dataFileWriter.setMeta(entry.getKey(), (byte[]) v);
         } else {
           throw new IllegalStateException(
               "Metadata value type must be one of String, Long, or byte[]. 
Found "
                   + v.getClass().getSimpleName());
         }
       }
-      writer.create(schema, Channels.newOutputStream(channel));
+      dataFileWriter.create(schema, Channels.newOutputStream(channel));
+      writer = dataFileWriter;
     }
 
     @Override
     public void write(ElementT element) throws IOException {
-      writer.append(element);
+      writer().append(element);
     }
 
     @Override
     public void flush() throws IOException {
-      writer.flush();
+      writer().flush();
+    }
+
+    private DataFileWriter<ElementT> writer() {
+      return checkStateNotNull(writer, "open() has not been called");
     }
   }
 
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSchemaIOProvider.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSchemaIOProvider.java
index 08a9f3a2946..85431c4d9fb 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSchemaIOProvider.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSchemaIOProvider.java
@@ -17,6 +17,8 @@
  */
 package org.apache.beam.sdk.extensions.avro.io;
 
+import static org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull;
+
 import com.google.auto.service.AutoService;
 import java.io.Serializable;
 import org.apache.avro.generic.GenericRecord;
@@ -45,9 +47,6 @@ import org.joda.time.Duration;
  */
 @Internal
 @AutoService(SchemaIOProvider.class)
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public class AvroSchemaIOProvider implements SchemaIOProvider {
   /** Returns an id that uniquely represents this IO. */
   @Override
@@ -69,8 +68,10 @@ public class AvroSchemaIOProvider implements 
SchemaIOProvider {
    * resides there, and some IO-specific configuration object.
    */
   @Override
-  public AvroSchemaIO from(String location, Row configuration, Schema 
dataSchema) {
-    return new AvroSchemaIO(location, dataSchema, configuration);
+  public AvroSchemaIO from(String location, Row configuration, @Nullable 
Schema dataSchema) {
+    // requiresDataSchema() is true, so callers must supply a data schema
+    return new AvroSchemaIO(
+        location, checkArgumentNotNull(dataSchema, "dataSchema is required"), 
configuration);
   }
 
   @Override
@@ -94,11 +95,8 @@ public class AvroSchemaIOProvider implements 
SchemaIOProvider {
     private AvroSchemaIO(String location, Schema dataSchema, Row 
configuration) {
       this.dataSchema = dataSchema;
       this.location = location;
-      if (configuration.getInt64("writeWindowSizeSeconds") != null) {
-        windowSize = 
Duration.standardSeconds(configuration.getInt64("writeWindowSizeSeconds"));
-      } else {
-        windowSize = null;
-      }
+      Long windowSizeSeconds = 
configuration.getInt64("writeWindowSizeSeconds");
+      windowSize = windowSizeSeconds == null ? null : 
Duration.standardSeconds(windowSizeSeconds);
     }
 
     @Override
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSink.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSink.java
index a6e6353235c..2ec4f429dde 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSink.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSink.java
@@ -17,6 +17,8 @@
  */
 package org.apache.beam.sdk.extensions.avro.io;
 
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+
 import java.io.Serializable;
 import java.nio.channels.Channels;
 import java.nio.channels.WritableByteChannel;
@@ -34,9 +36,6 @@ import org.apache.beam.sdk.util.MimeTypes;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** A {@link FileBasedSink} for Avro files. */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public class AvroSink<UserT, DestinationT, OutputT>
     extends FileBasedSink<UserT, DestinationT, OutputT> {
   private final Class<OutputT> type;
@@ -121,33 +120,38 @@ public class AvroSink<UserT, DestinationT, OutputT>
               .orElse(AvroDatumFactory.of(type))
               .apply(schema);
 
-      dataFileWriter = new DataFileWriter<>(datumWriter).setCodec(codec);
+      DataFileWriter<OutputT> writer = new 
DataFileWriter<>(datumWriter).setCodec(codec);
       for (Map.Entry<String, Object> entry : metadata.entrySet()) {
         Object v = entry.getValue();
         if (v instanceof String) {
-          dataFileWriter.setMeta(entry.getKey(), (String) v);
+          writer.setMeta(entry.getKey(), (String) v);
         } else if (v instanceof Long) {
-          dataFileWriter.setMeta(entry.getKey(), (Long) v);
+          writer.setMeta(entry.getKey(), (Long) v);
         } else if (v instanceof byte[]) {
-          dataFileWriter.setMeta(entry.getKey(), (byte[]) v);
+          writer.setMeta(entry.getKey(), (byte[]) v);
         } else {
           throw new IllegalStateException(
               "Metadata value type must be one of String, Long, or byte[]. 
Found "
                   + v.getClass().getSimpleName());
         }
       }
-      dataFileWriter.setSyncInterval(syncInterval);
-      dataFileWriter.create(schema, Channels.newOutputStream(channel));
+      writer.setSyncInterval(syncInterval);
+      writer.create(schema, Channels.newOutputStream(channel));
+      dataFileWriter = writer;
     }
 
     @Override
     public void write(OutputT value) throws Exception {
-      dataFileWriter.append(value);
+      dataFileWriter().append(value);
     }
 
     @Override
     protected void finishWrite() throws Exception {
-      dataFileWriter.flush();
+      dataFileWriter().flush();
+    }
+
+    private DataFileWriter<OutputT> dataFileWriter() {
+      return checkStateNotNull(dataFileWriter, "prepareWrite() has not been 
called");
     }
   }
 }
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSource.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSource.java
index 6d65c800c0a..bd21a0d1412 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSource.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/AvroSource.java
@@ -18,9 +18,9 @@
 package org.apache.beam.sdk.extensions.avro.io;
 
 import static 
org.apache.beam.sdk.io.FileBasedSource.Mode.SINGLE_FILE_OR_SUBRANGE;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
-import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -125,9 +125,6 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  */
 // CHECKSTYLE.ON: JavadocStyle
 
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 public class AvroSource<T> extends BlockBasedSource<T> {
   // Default minimum bundle size (chosen as two default-size Avro blocks to 
attempt to
   // ensure that every source has at least one block of records).
@@ -174,7 +171,8 @@ public class AvroSource<T> extends BlockBasedSource<T> {
         @Nullable Coder<T> outputCoder,
         @Nullable DatumReaderFactory<?> readerFactory) {
       this.type = type;
-      this.readerSchemaString = internSchemaString(readerSchemaString);
+      this.readerSchemaString =
+          readerSchemaString == null ? null : 
internSchemaString(readerSchemaString);
       this.parseFn = parseFn;
       this.outputCoder = outputCoder;
       this.readerFactory = readerFactory;
@@ -182,21 +180,24 @@ public class AvroSource<T> extends BlockBasedSource<T> {
 
     private void readObject(ObjectInputStream is) throws IOException, 
ClassNotFoundException {
       is.defaultReadObject();
-      readerSchemaString = internSchemaString(readerSchemaString);
+      String schemaString = readerSchemaString;
+      readerSchemaString = schemaString == null ? null : 
internSchemaString(schemaString);
     }
 
     private Coder<T> getOutputCoder() {
       if (parseFn == null && outputCoder == null) {
+        // validate() rejects a null readerSchemaString when there is no parse 
fn
+        Schema readerSchema =
+            internOrParseSchemaString(checkStateNotNull(readerSchemaString, 
"readerSchemaString"));
         if (readerFactory != null && readerFactory instanceof 
AvroDatumFactory) {
           // create custom AvroCoder from the AvroDatumFactory
-          return AvroCoder.of(
-              (AvroDatumFactory<T>) readerFactory, 
internOrParseSchemaString(readerSchemaString));
+          return AvroCoder.of((AvroDatumFactory<T>) readerFactory, 
readerSchema);
         } else {
           // fallback with default avro coder for the type & schema
-          return AvroCoder.of((Class<T>) type, 
internOrParseSchemaString(readerSchemaString));
+          return AvroCoder.of((Class<T>) type, readerSchema);
         }
       } else {
-        return outputCoder;
+        return checkStateNotNull(outputCoder, "outputCoder is required when 
using a parse fn");
       }
     }
 
@@ -476,7 +477,7 @@ public class AvroSource<T> extends BlockBasedSource<T> {
     String schemaString = null;
     byte[] syncMarker;
     try (InputStream stream = 
Channels.newInputStream(FileSystems.open(fileResource))) {
-      BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(stream, null);
+      BinaryDecoder decoder = newBinaryDecoder(stream);
 
       // The header of an object container file begins with a four-byte magic 
number, followed
       // by the file metadata (including the schema and codec), encoded as a 
map. Finally, the
@@ -519,8 +520,16 @@ public class AvroSource<T> extends BlockBasedSource<T> {
       syncMarker = new byte[DataFileConstants.SYNC_SIZE];
       decoder.readFixed(syncMarker);
     }
-    checkState(schemaString != null, "No schema present in Avro file metadata 
%s", fileResource);
-    return new AvroMetadata(syncMarker, codec, schemaString);
+    return new AvroMetadata(
+        syncMarker,
+        codec,
+        checkStateNotNull(
+            schemaString, "No schema present in Avro file metadata %s", 
fileResource));
+  }
+
+  @SuppressWarnings("nullness") // DecoderFactory accepts a null reuse but is 
unannotated
+  private static BinaryDecoder newBinaryDecoder(InputStream stream) {
+    return DecoderFactory.get().binaryDecoder(stream, null);
   }
 
   // A logical reference cache used to store schemas and schema strings to 
allow us to
@@ -583,12 +592,14 @@ public class AvroSource<T> extends BlockBasedSource<T> {
 
     private final Iterator<?> iterator;
 
-    private final SerializableFunction<GenericRecord, T> parseFn;
+    private final @Nullable SerializableFunction<GenericRecord, T> parseFn;
 
     private final long numRecordsInBlock;
 
     AvroBlock(
-        Iterator<?> iter, SerializableFunction<GenericRecord, T> parseFn, long 
numRecordsInBlock) {
+        Iterator<?> iter,
+        @Nullable SerializableFunction<GenericRecord, T> parseFn,
+        long numRecordsInBlock) {
       this.iterator = iter;
       this.parseFn = parseFn;
       this.numRecordsInBlock = numRecordsInBlock;
@@ -596,7 +607,7 @@ public class AvroSource<T> extends BlockBasedSource<T> {
 
     @Override
     public T getCurrentRecord() {
-      return currentRecord;
+      return checkStateNotNull(currentRecord, "readNextRecord() has not been 
called");
     }
 
     @Override
@@ -605,7 +616,7 @@ public class AvroSource<T> extends BlockBasedSource<T> {
         return false;
       }
 
-      Object record = iterator.next();
+      Object record = checkStateNotNull(iterator.next(), "Avro block contained 
a null record");
       currentRecord = (parseFn == null) ? ((T) record) : 
parseFn.apply((GenericRecord) record);
       currentRecordIndex++;
       return true;
@@ -699,34 +710,38 @@ public class AvroSource<T> extends BlockBasedSource<T> {
     // Postcondition: same as above, but for the new current (formerly next) 
block.
     @Override
     public boolean readNextBlock() {
-      if (!dataFileReader.hasNext()) {
+      DataFileReader<?> reader = dataFileReader();
+      if (!reader.hasNext()) {
         return false;
       }
 
       long headerLength =
-          (long) VarInt.getLength(dataFileReader.getBlockCount())
-              + VarInt.getLength(dataFileReader.getBlockSize())
+          (long) VarInt.getLength(reader.getBlockCount())
+              + VarInt.getLength(reader.getBlockSize())
               + DataFileConstants.SYNC_SIZE;
 
       currentBlock =
-          new AvroBlock<>(
-              dataFileReader, getCurrentSource().mode.parseFn, 
dataFileReader.getBlockCount());
+          new AvroBlock<>(reader, getCurrentSource().mode.parseFn, 
reader.getBlockCount());
 
       // Atomically update both the position and offset of the new block.
       synchronized (progressLock) {
-        currentBlockOffset = dataFileReader.previousSync();
+        currentBlockOffset = reader.previousSync();
         // Total block size includes the header, block content, and trailing 
sync marker.
-        currentBlockSizeBytes = dataFileReader.getBlockSize() + headerLength;
+        currentBlockSizeBytes = reader.getBlockSize() + headerLength;
       }
 
       return true;
     }
 
     @Override
-    public AvroBlock<T> getCurrentBlock() {
+    public @Nullable AvroBlock<T> getCurrentBlock() {
       return currentBlock;
     }
 
+    private DataFileReader<?> dataFileReader() {
+      return checkStateNotNull(dataFileReader, "startReading() has not been 
called");
+    }
+
     @Override
     public long getCurrentBlockOffset() {
       synchronized (progressLock) {
@@ -773,23 +788,33 @@ public class AvroSource<T> extends BlockBasedSource<T> {
       }
 
       DatumReader<?> reader =
-          
Optional.<DatumReaderFactory<?>>ofNullable(this.getCurrentSource().mode.readerFactory)
-              .orElse(AvroDatumFactory.of(this.getCurrentSource().mode.type))
-              .apply(readerSchema, readerSchema);
+          newDatumReader(
+              
Optional.<DatumReaderFactory<?>>ofNullable(this.getCurrentSource().mode.readerFactory)
+                  
.orElse(AvroDatumFactory.of(this.getCurrentSource().mode.type)),
+              readerSchema);
 
-      dataFileReader = new DataFileReader<>(seekableChannelInput, reader);
+      DataFileReader<?> fileReader = new 
DataFileReader<>(seekableChannelInput, reader);
 
       long startOffset = getCurrentSource().getStartOffset();
       if (startOffset != 0) {
         // the start offset may be in the middle of a sync marker, by 
rewinding SYNC_SIZE bytes we
         // ensure that we won't miss the block if so.
-        dataFileReader.sync(Math.max(0, startOffset - 
DataFileConstants.SYNC_SIZE));
+        fileReader.sync(Math.max(0, startOffset - 
DataFileConstants.SYNC_SIZE));
       }
 
       synchronized (progressLock) {
-        currentBlockOffset = dataFileReader.previousSync();
+        currentBlockOffset = fileReader.previousSync();
         currentBlockSizeBytes = 0;
       }
+      dataFileReader = fileReader;
+    }
+
+    // A null schema tells the factory to derive both the writer and the 
reader schema from the
+    // file header. DatumReaderFactory predates nullness annotations and 
cannot express that.
+    @SuppressWarnings("nullness")
+    private static <X> DatumReader<X> newDatumReader(
+        DatumReaderFactory<X> factory, @Nullable Schema schema) {
+      return factory.apply(schema, schema);
     }
   }
 }
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/ConstantAvroDestination.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/ConstantAvroDestination.java
index 5b3683c349c..d92cef53b34 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/ConstantAvroDestination.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/ConstantAvroDestination.java
@@ -32,9 +32,6 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.io.BaseEncodin
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** Always returns a constant {@link FilenamePolicy}, {@link Schema}, 
metadata, and codec. */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 class ConstantAvroDestination<UserT, OutputT>
     extends DynamicAvroDestinations<UserT, Void, OutputT> {
   private static class SchemaFunction implements Serializable, 
Function<String, Schema> {
@@ -51,7 +48,7 @@ class ConstantAvroDestination<UserT, OutputT>
   private final Map<String, Object> metadata;
   private final SerializableAvroCodecFactory codec;
   private final SerializableFunction<UserT, OutputT> formatFunction;
-  private final AvroSink.DatumWriterFactory<OutputT> datumWriterFactory;
+  private final AvroSink.@Nullable DatumWriterFactory<OutputT> 
datumWriterFactory;
 
   private class Metadata implements HasDisplayData {
     @Override
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/SerializableAvroCodecFactory.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/SerializableAvroCodecFactory.java
index 215d3b4dd5f..fec1b7ed016 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/SerializableAvroCodecFactory.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/io/SerializableAvroCodecFactory.java
@@ -40,9 +40,6 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  * A wrapper that allows {@link CodecFactory}s to be serialized using Java's 
standard serialization
  * mechanisms.
  */
-@SuppressWarnings({
-  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
-})
 class SerializableAvroCodecFactory implements Externalizable {
   private static final long serialVersionUID = 7445324844109564303L;
   private static final List<String> noOptAvroCodecs =
@@ -64,7 +61,7 @@ class SerializableAvroCodecFactory implements Externalizable {
     this.codecFactory = codecFactory;
   }
 
-  private boolean checkIsSupportedCodec(CodecFactory codecFactory) {
+  private static boolean checkIsSupportedCodec(CodecFactory codecFactory) {
     final String codecStr = codecFactory.toString();
     return noOptAvroCodecs.contains(codecStr)
         || deflatePattern.matcher(codecStr).matches()
@@ -74,7 +71,7 @@ class SerializableAvroCodecFactory implements Externalizable {
 
   @Override
   public void writeExternal(ObjectOutput out) throws IOException {
-    out.writeUTF(codecFactory.toString());
+    out.writeUTF(getCodec().toString());
   }
 
   @Override
@@ -91,32 +88,37 @@ class SerializableAvroCodecFactory implements 
Externalizable {
 
     Matcher deflateMatcher = deflatePattern.matcher(codecStr);
     if (deflateMatcher.find()) {
-      codecFactory = 
CodecFactory.deflateCodec(Integer.parseInt(deflateMatcher.group("level")));
+      codecFactory = CodecFactory.deflateCodec(matchedLevel(deflateMatcher));
       return;
     }
 
     Matcher xzMatcher = xzPattern.matcher(codecStr);
     if (xzMatcher.find()) {
-      codecFactory = 
CodecFactory.xzCodec(Integer.parseInt(xzMatcher.group("level")));
+      codecFactory = CodecFactory.xzCodec(matchedLevel(xzMatcher));
       return;
     }
 
     Matcher zstdMatcher = zstdPattern.matcher(codecStr);
     if (zstdMatcher.find()) {
-      codecFactory = 
CodecFactory.zstandardCodec(Integer.parseInt(zstdMatcher.group("level")));
+      codecFactory = CodecFactory.zstandardCodec(matchedLevel(zstdMatcher));
       return;
     }
 
     throw new IllegalStateException(codecStr + " is not supported");
   }
 
+  /** Reads the {@code level} group of a matcher that has just matched 
successfully. */
+  private static int matchedLevel(Matcher matcher) {
+    return Integer.parseInt(checkNotNull(matcher.group("level")));
+  }
+
   public CodecFactory getCodec() {
-    return codecFactory;
+    return checkNotNull(
+        codecFactory, "Inner CodecFactory is null, please use non default 
constructor");
   }
 
   @Override
   public String toString() {
-    checkNotNull(codecFactory, "Inner CodecFactory is null, please use non 
default constructor");
-    return codecFactory.toString();
+    return getCodec().toString();
   }
 }
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroByteBuddyUtils.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroByteBuddyUtils.java
index 0a82663c177..1812a5738c3 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroByteBuddyUtils.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroByteBuddyUtils.java
@@ -46,10 +46,7 @@ import org.apache.beam.sdk.util.common.ReflectHelpers;
 import org.apache.beam.sdk.values.TypeDescriptor;
 import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Maps;
 
-@SuppressWarnings({
-  "nullness", // TODO(https://github.com/apache/beam/issues/20497)
-  "rawtypes"
-})
+@SuppressWarnings({"rawtypes"})
 class AvroByteBuddyUtils {
   private static final ByteBuddy BYTE_BUDDY = new ByteBuddy();
 
diff --git 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtils.java
 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtils.java
index 853d43ce3e7..302c524722a 100644
--- 
a/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtils.java
+++ 
b/sdks/java/extensions/avro/src/main/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtils.java
@@ -35,6 +35,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
@@ -151,13 +152,12 @@ import org.joda.time.ReadableInstant;
  *
  * is used.
  */
-@SuppressWarnings({
-  "nullness", // TODO(https://github.com/apache/beam/issues/20497)
-  "rawtypes"
-})
+@SuppressWarnings({"rawtypes"})
 public class AvroUtils {
   public static final String VERSION_AVRO =
-      org.apache.avro.Schema.class.getPackage().getImplementationVersion();
+      Optional.ofNullable(org.apache.avro.Schema.class.getPackage())
+          .map(Package::getImplementationVersion)
+          .orElse("");
   private static final ForLoadedType BYTES = new ForLoadedType(byte[].class);
   private static final ForLoadedType JAVA_INSTANT = new 
ForLoadedType(java.time.Instant.class);
   private static final ForLoadedType JAVA_LOCALE_DATE =
@@ -196,6 +196,12 @@ public class AvroUtils {
       builder.set(fieldName, castToNonNull(value));
     }
 
+    private static org.apache.avro.Schema.Field newFieldWithoutDefault(
+        String name, org.apache.avro.Schema schema, String doc) {
+      // a null defaultValue means "no default", but the parameter lacks the 
@Nullable annotation
+      return new org.apache.avro.Schema.Field(name, schema, doc, 
castToNonNull(null));
+    }
+
     private static Object createFixed(
         @Nullable Object old, byte[] bytes, org.apache.avro.Schema schema) {
       // old is tolerated when null, due to an instanceof check
@@ -528,8 +534,8 @@ public class AvroUtils {
   public static org.apache.avro.Schema.Field toAvroField(Field field, String 
namespace) {
     org.apache.avro.Schema fieldSchema =
         getFieldSchema(field.getType(), field.getName(), namespace);
-    return new org.apache.avro.Schema.Field(
-        field.getName(), fieldSchema, field.getDescription(), (Object) null);
+    return NullnessCheckerWorkarounds.newFieldWithoutDefault(
+        field.getName(), fieldSchema, field.getDescription());
   }
 
   private AvroUtils() {}
@@ -1437,8 +1443,8 @@ public class AvroUtils {
     }
   }
 
-  private static Object convertLogicalType(
-      @PolyNull Object value,
+  private static @Nullable Object convertLogicalType(
+      @Nonnull Object value,
       @Nonnull org.apache.avro.Schema avroSchema,
       @Nonnull FieldType fieldType,
       @Nonnull GenericData genericData) {
@@ -1447,11 +1453,11 @@ public class AvroUtils {
     // TODO: Remove this workaround once Avro is upgraded to 1.12+ where 
timestamp-nanos
     if (TIMESTAMP_NANOS_LOGICAL_TYPE.equals(type.type.getProp("logicalType"))) 
{
       if (type.type.getType() == org.apache.avro.Schema.Type.LONG) {
-        Long nanos = (Long) value;
+        long nanos = (Long) value;
         // Check if Beam expects Timestamp logical type
         if (fieldType.getTypeName() == TypeName.LOGICAL_TYPE
             && 
org.apache.beam.sdk.schemas.logicaltypes.Timestamp.IDENTIFIER.equals(
-                fieldType.getLogicalType().getIdentifier())) {
+                checkNotNull(fieldType.getLogicalType()).getIdentifier())) {
           long seconds = Math.floorDiv(nanos, 1_000_000_000L);
           long nanoAdjustment = Math.floorMod(nanos, 1_000_000_000L);
           return java.time.Instant.ofEpochSecond(seconds, nanoAdjustment);
@@ -1473,7 +1479,10 @@ public class AvroUtils {
     if (conversion != null) {
       convertedType = conversion.getConvertedType();
       if (convertedType.isInstance(value)) {
-        rawType = Conversions.convertToRawType(value, avroSchema, logicalType, 
conversion);
+        // type.type rather than avroSchema: Conversions.convertToRawType 
switches on the schema
+        // type and silently returns the value unconverted for a UNION, so a 
nullable field would
+        // never get converted.
+        rawType = Conversions.convertToRawType(value, type.type, logicalType, 
conversion);
       }
     }
 
@@ -1786,8 +1795,8 @@ public class AvroUtils {
       Object value,
       LogicalType logicalType,
       Object rawType,
-      Conversion<?> conversion,
-      Class<?> convertedType) {
+      @Nullable Conversion<?> conversion,
+      @Nullable Class<?> convertedType) {
     String msg =
         String.format(
             "Value %s of class %s is not a supported type for logical type %s 
(%s). "
diff --git 
a/sdks/java/extensions/avro/src/test/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtilsTest.java
 
b/sdks/java/extensions/avro/src/test/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtilsTest.java
index d2452452f14..928cdbfe10b 100644
--- 
a/sdks/java/extensions/avro/src/test/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtilsTest.java
+++ 
b/sdks/java/extensions/avro/src/test/java/org/apache/beam/sdk/extensions/avro/schemas/utils/AvroUtilsTest.java
@@ -73,6 +73,7 @@ import org.joda.time.DateTimeFieldType;
 import org.joda.time.DateTimeZone;
 import org.joda.time.Days;
 import org.joda.time.Instant;
+import org.joda.time.LocalDate;
 import org.joda.time.LocalTime;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -1112,10 +1113,90 @@ public class AvroUtilsTest {
 
     // Alternatively, a timestamp-millis logical type can have a joda datum.
     genericRecord.put("timestampMillis", new 
DateTime(genericRecord.get("timestampMillis")));
-    row = AvroUtils.toBeamRowStrict(getGenericRecord(), null);
+    row = AvroUtils.toBeamRowStrict(genericRecord, null);
     assertEquals(getBeamRow(), row);
   }
 
+  @Test
+  public void testNullableLogicalTypeGenericRecordToBeamRow() {
+    org.apache.avro.Schema decimalSchema =
+        LogicalTypes.decimal(Integer.MAX_VALUE)
+            
.addToSchema(org.apache.avro.Schema.create(org.apache.avro.Schema.Type.BYTES));
+    org.apache.avro.Schema avroSchema =
+        org.apache.avro.Schema.createRecord(
+            "topLevelRecord",
+            null,
+            null,
+            false,
+            Lists.newArrayList(
+                new org.apache.avro.Schema.Field(
+                    "date",
+                    ReflectData.makeNullable(
+                        LogicalTypes.date()
+                            .addToSchema(
+                                
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.INT))),
+                    "",
+                    (Object) null),
+                new org.apache.avro.Schema.Field(
+                    "timestampMillis",
+                    ReflectData.makeNullable(
+                        LogicalTypes.timestampMillis()
+                            .addToSchema(
+                                
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.LONG))),
+                    "",
+                    (Object) null),
+                new org.apache.avro.Schema.Field(
+                    "decimal", ReflectData.makeNullable(decimalSchema), "", 
(Object) null)));
+
+    Schema beamSchema =
+        Schema.builder()
+            .addNullableField("date", FieldType.DATETIME)
+            .addNullableField("timestampMillis", FieldType.DATETIME)
+            .addNullableField("decimal", FieldType.DECIMAL)
+            .build();
+    assertEquals(beamSchema, AvroUtils.toBeamSchema(avroSchema));
+
+    // Data written through a GenericData with logical type conversions 
registered carries the
+    // converted values (joda LocalDate/DateTime, BigDecimal) rather than the 
raw int/long/bytes.
+    GenericRecord converted =
+        new GenericRecordBuilder(avroSchema)
+            .set("date", new LocalDate(1979, 3, 14))
+            .set("timestampMillis", DATE_TIME)
+            .set("decimal", BIG_DECIMAL)
+            .build();
+    assertEquals(
+        Row.withSchema(beamSchema)
+            .addValues(new DateTime(1979, 3, 14, 0, 0, DateTimeZone.UTC), 
DATE_TIME, BIG_DECIMAL)
+            .build(),
+        AvroUtils.toBeamRowStrict(converted, beamSchema));
+
+    // The same fields holding their unconverted avro representations.
+    GenericRecord raw =
+        new GenericRecordBuilder(avroSchema)
+            .set("date", (int) java.time.LocalDate.of(1979, 3, 
14).toEpochDay())
+            .set("timestampMillis", DATE_TIME.getMillis())
+            .set(
+                "decimal",
+                new Conversions.DecimalConversion()
+                    .toBytes(BIG_DECIMAL, decimalSchema, 
decimalSchema.getLogicalType()))
+            .build();
+    assertEquals(
+        Row.withSchema(beamSchema)
+            .addValues(new DateTime(1979, 3, 14, 0, 0, DateTimeZone.UTC), 
DATE_TIME, BIG_DECIMAL)
+            .build(),
+        AvroUtils.toBeamRowStrict(raw, beamSchema));
+
+    GenericRecord nulls =
+        new GenericRecordBuilder(avroSchema)
+            .set("date", null)
+            .set("timestampMillis", null)
+            .set("decimal", null)
+            .build();
+    assertEquals(
+        Row.withSchema(beamSchema).addValues(null, null, null).build(),
+        AvroUtils.toBeamRowStrict(nulls, beamSchema));
+  }
+
   @Test
   public void testGenericRecordToRowFunction() {
     
SerializableUtils.ensureSerializable(AvroUtils.getGenericRecordToRowFunction(Schema.of()));

Reply via email to