content: /*
   * Licensed to the Apache Software Foundation (ASF) under one
   * or more contributor license agreements.  See the NOTICE file
   * distributed with this work for additional information
   * regarding copyright ownership.  The ASF licenses this file
   * to you under the Apache License, Version 2.0 (the
   * "License"); you may not use this file except in compliance
   * with the License.  You may obtain a copy of the License at
   *
   *     http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  package org.apache.beam.sdk.fn.data;
  
  import java.io.IOException;
  import java.util.Collections;
  import java.util.HashMap;
  import java.util.List;
  import java.util.Map;
  import java.util.Objects;
  import java.util.concurrent.CancellationException;
  import java.util.concurrent.ExecutionException;
  import java.util.concurrent.Executors;
  import java.util.concurrent.ScheduledFuture;
  import java.util.concurrent.TimeUnit;
  import java.util.function.Supplier;
  import javax.annotation.Nullable;
  import javax.annotation.concurrent.NotThreadSafe;
  import org.apache.beam.model.fnexecution.v1.BeamFnApi.Elements;
  import org.apache.beam.sdk.coders.Coder;
  import org.apache.beam.sdk.options.ExperimentalOptions;
  import org.apache.beam.sdk.options.PipelineOptions;
  import org.apache.beam.sdk.util.ByteStringOutputStream;
  import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.ByteString;
  import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.stub.StreamObserver;
  import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
  import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.ThreadFactoryBuilder;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  
  /**
   * An outbound data buffering aggregator with size-based buffer and time-based buffer if
   * corresponding options are set.
   *
   * <p>The default size-based buffer threshold can be overridden by specifying the experiment {@code
   * data_buffer_size_limit=<bytes>}
   *
   * <p>The default time-based buffer threshold can be overridden by specifying the experiment {@code
   * data_buffer_time_limit_ms=<milliseconds>}
   */
  @SuppressWarnings({
    "nullness" // TODO(https://github.com/apache/beam/issues/20497)
  })
  // The calling thread that invokes sendBufferedDataAndFinishOutboundStreams synchronizes on
  // flushLock effectively making the periodic flushing no longer read or mutate hasFlushedForBundle
  // and allowing the calling thread to read and mutate hasFlushedForBundle safely without needing to
  // create another memory barrier. Also note that flush is always invoked when synchronizing on
  // flushLock when there is a periodic flushing thread.
  @NotThreadSafe
  public class BeamFnDataOutboundAggregator {
  
    public static final String DATA_BUFFER_SIZE_LIMIT = "data_buffer_size_limit=";
    public static final int DEFAULT_BUFFER_LIMIT_BYTES = 1_000_000;
    public static final String DATA_BUFFER_TIME_LIMIT_MS = "data_buffer_time_limit_ms=";
    public static final long DEFAULT_BUFFER_LIMIT_TIME_MS = -1L;
  
    private static final Logger LOG = LoggerFactory.getLogger(BeamFnDataOutboundAggregator.class);
    private final int sizeLimit;
    private final long timeLimit;
    private final Supplier<String> processBundleRequestIdSupplier;
    @VisibleForTesting final Map<String, Receiver<?>> outputDataReceivers;
    @VisibleForTesting final Map<TimerEndpoint, Receiver<?>> outputTimersReceivers;
    private final StreamObserver<Elements> outboundObserver;
    @Nullable @VisibleForTesting ScheduledFuture<?> flushFuture;
    private long bytesWrittenSinceFlush;
    private final Object flushLock;
    private final boolean collectElementsIfNoFlushes;
    private boolean hasFlushedForBundle;
  
    public BeamFnDataOutboundAggregator(
        PipelineOptions options,
        Supplier<String> processBundleRequestIdSupplier,
        StreamObserver<Elements> outboundObserver,
        boolean collectElementsIfNoFlushes) {
      this.sizeLimit = getSizeLimit(options);
      this.timeLimit = getTimeLimit(options);
      this.collectElementsIfNoFlushes = collectElementsIfNoFlushes;
      this.outputDataReceivers = new HashMap<>();
      this.outputTimersReceivers = new HashMap<>();
      this.outboundObserver = outboundObserver;
      this.processBundleRequestIdSupplier = processBundleRequestIdSupplier;
      this.bytesWrittenSinceFlush = 0L;
      this.flushLock = new Object();
      this.hasFlushedForBundle = false;
    }
  
    /** Starts the flushing daemon thread if data_buffer_time_limit_ms is set. */
    public void start() {
      if (timeLimit > 0 && this.flushFuture == null) {
        this.flushFuture =
            Executors.newSingleThreadScheduledExecutor(
                    new ThreadFactoryBuilder()
                        .setDaemon(true)
                        .setNameFormat("DataBufferOutboundFlusher-thread")
                        .build())
                .scheduleAtFixedRate(this::flush, timeLimit, timeLimit, TimeUnit.MILLISECONDS);
      }
    }
  
    /**
     * Register the outbound data logical endpoint, returns the FnDataReceiver for processing the
     * endpoint's outbound data.
     */
    public <T> FnDataReceiver<T> registerOutputDataLocation(String pTransformId, Coder<T> coder) {
      if (outputDataReceivers.containsKey(pTransformId)) {
        throw new IllegalStateException(
            "Outbound data endpoint already registered for " + pTransformId);
      }
      Receiver<T> receiver = new Receiver<>(coder);
      if (timeLimit > 0) {
        outputDataReceivers.put(pTransformId, receiver);
        return data -> {
          checkFlushThreadException();
          synchronized (flushLock) {
            receiver.accept(data);
          }
        };
      }
      outputDataReceivers.put(pTransformId, receiver);
      return receiver;
    }
  
    /**
     * Register the outbound timers logical endpoint, returns the FnDataReceiver for processing the
     * endpoint's outbound timers data.
     */
    public <T> FnDataReceiver<T> registerOutputTimersLocation(
        String pTransformId, String timerFamilyId, Coder<T> coder) {
      TimerEndpoint timerKey = new TimerEndpoint(pTransformId, timerFamilyId);
      if (outputTimersReceivers.containsKey(timerKey)) {
        throw new IllegalStateException(
            "Outbound timers endpoint already registered for " + timerKey);
      }
      Receiver<T> receiver = new Receiver<>(coder);
      if (timeLimit > 0) {
        outputTimersReceivers.put(timerKey, receiver);
        return timers -> {
          checkFlushThreadException();
          synchronized (flushLock) {
            receiver.accept(timers);
          }
        };
      }
      outputTimersReceivers.put(timerKey, receiver);
      return receiver;
    }
  
    private void flushInternal() {
      if (bytesWrittenSinceFlush == 0) {
        return;
      }
      Elements.Builder elements = convertBufferForTransmission();
      if (elements.getDataCount() > 0 || elements.getTimersCount() > 0) {
        outboundObserver.onNext(elements.build());
      }
      hasFlushedForBundle = true;
    }
  
    /**
     * Closes the streams for all registered outbound endpoints. Should be called at the end of each
     * bundle. Returns the buffered Elements if the BeamFnDataOutboundAggregator started with
     * collectElementsIfNoFlushes=true, and there was no previous flush in this bundle, otherwise
     * returns null.
     */
    public Elements sendOrCollectBufferedDataAndFinishOutboundStreams() {
      if (outputTimersReceivers.isEmpty() && outputDataReceivers.isEmpty()) {
        return null;
      }
      Elements.Builder bufferedElements;
      if (timeLimit > 0) {
        synchronized (flushLock) {
          bufferedElements = convertBufferForTransmission();
        }
      } else {
        bufferedElements = convertBufferForTransmission();
      }
      LOG.debug(
          "Closing streams for instruction {} and outbound data {} and timers {}.",
          processBundleRequestIdSupplier.get(),
          outputDataReceivers,
          outputTimersReceivers);
      for (Map.Entry<String, Receiver<?>> entry : outputDataReceivers.entrySet()) {
        String pTransformId = entry.getKey();
        bufferedElements
            .addDataBuilder()
            .setInstructionId(processBundleRequestIdSupplier.get())
            .setTransformId(pTransformId)
            .setIsLast(true);
        entry.getValue().resetStats();
      }
      for (Map.Entry<TimerEndpoint, Receiver<?>> entry : outputTimersReceivers.entrySet()) {
        TimerEndpoint timerKey = entry.getKey();
        bufferedElements
            .addTimersBuilder()
            .setInstructionId(processBundleRequestIdSupplier.get())
            .setTransformId(timerKey.pTransformId)
            .setTimerFamilyId(timerKey.timerFamilyId)
            .setIsLast(true);
        entry.getValue().resetStats();
      }
      if (collectElementsIfNoFlushes && !hasFlushedForBundle) {
        return bufferedElements.build();
      }
      outboundObserver.onNext(bufferedElements.build());
      // This is now at the end of a bundle, so we reset hasFlushedForBundle to prepare for new
      // bundles.
      hasFlushedForBundle = false;
      return null;
    }
  
    // Send the elements to the StreamObserver associated with this aggregator.
    public void sendElements(Elements elements) {
      outboundObserver.onNext(elements);
    }
  
    public void discard() {
      if (flushFuture != null) {
        flushFuture.cancel(true);
      }
    }
  
    private Elements.Builder convertBufferForTransmission() {
      Elements.Builder bufferedElements = Elements.newBuilder();
      for (Map.Entry<String, Receiver<?>> entry : outputDataReceivers.entrySet()) {
        if (entry.getValue().bufferedSize() == 0) {
          continue;
        }
        ByteString bytes = entry.getValue().toByteStringAndResetBuffer();
        bufferedElements
            .addDataBuilder()
            .setInstructionId(processBundleRequestIdSupplier.get())
            .setTransformId(entry.getKey())
            .setData(bytes);
      }
      for (Map.Entry<TimerEndpoint, Receiver<?>> entry : outputTimersReceivers.entrySet()) {
        if (entry.getValue().bufferedSize() == 0) {
          continue;
        }
        ByteString bytes = entry.getValue().toByteStringAndResetBuffer();
        bufferedElements
            .addTimersBuilder()
            .setInstructionId(processBundleRequestIdSupplier.get())
            .setTransformId(entry.getKey().pTransformId)
            .setTimerFamilyId(entry.getKey().timerFamilyId)
            .setTimers(bytes);
      }
      bytesWrittenSinceFlush = 0L;
      return bufferedElements;
    }
  
    void flush() {
      try {
        synchronized (flushLock) {
          flushInternal();
        }
      } catch (Throwable t) {
        throw new RuntimeException(t);
      }
    }
  
    /** Check if the flush thread failed with an exception. */
    private void checkFlushThreadException() throws IOException {
      if (timeLimit > 0 && flushFuture.isDone()) {
        try {
          flushFuture.get();
          throw new IOException("Periodic flushing thread finished unexpectedly.");
        } catch (ExecutionException ee) {
          unwrapExecutionException(ee);
        } catch (CancellationException ce) {
          throw new IOException(ce);
        } catch (InterruptedException ie) {
          Thread.currentThread().interrupt();
          throw new IOException(ie);
        }
      }
    }
  
    private void unwrapExecutionException(ExecutionException ee) throws IOException {
      // the cause is always RuntimeException
      RuntimeException re = (RuntimeException) ee.getCause();
      if (re.getCause() instanceof IOException) {
        throw (IOException) re.getCause();
      } else {
        throw new IOException(re.getCause());
      }
    }
  
    private static int getSizeLimit(PipelineOptions options) {
      List<String> experiments = options.as(ExperimentalOptions.class).getExperiments();
      for (String experiment : experiments == null ? Collections.<String>emptyList() : experiments) {
        if (experiment.startsWith(DATA_BUFFER_SIZE_LIMIT)) {
          return Integer.parseInt(experiment.substring(DATA_BUFFER_SIZE_LIMIT.length()));
        }
      }
      return DEFAULT_BUFFER_LIMIT_BYTES;
    }
  
    private static long getTimeLimit(PipelineOptions options) {
      List<String> experiments = options.as(ExperimentalOptions.class).getExperiments();
      for (String experiment : experiments == null ? Collections.<String>emptyList() : experiments) {
        if (experiment.startsWith(DATA_BUFFER_TIME_LIMIT_MS)) {
          return Long.parseLong(experiment.substring(DATA_BUFFER_TIME_LIMIT_MS.length()));
        }
      }
      return DEFAULT_BUFFER_LIMIT_TIME_MS;
    }
  
    @VisibleForTesting
    class Receiver<T> implements FnDataReceiver<T> {
      private final ByteStringOutputStream output;
      private final Coder<T> coder;
      private long perBundleByteCount;
      private long perBundleElementCount;
  
      public Receiver(Coder<T> coder) {
        this.output = new ByteStringOutputStream();
        this.coder = coder;
        this.perBundleByteCount = 0L;
        this.perBundleElementCount = 0L;
      }
  
      @Override
      public void accept(T input) throws Exception {
        int size = output.size();
        coder.encode(input, output);
        if (output.size() - size == 0) {
          output.write(0);
        }
        final long delta = (long) output.size() - size;
        bytesWrittenSinceFlush += delta;
        perBundleByteCount += delta;
        perBundleElementCount += 1;
        if (bytesWrittenSinceFlush > sizeLimit) {
          flushInternal();
        }
      }
  
      public long getByteCount() {
        return perBundleByteCount;
      }
  
      public long getElementCount() {
        return perBundleElementCount;
      }
  
      public int bufferedSize() {
        return output.size();
      }
  
      public ByteString toByteStringAndResetBuffer() {
        return this.output.toByteStringAndReset();
      }
  
      public void resetStats() {
        this.perBundleElementCount = 0L;
        this.perBundleByteCount = 0L;
      }
  
      @Override
      public String toString() {
        return String.format(
            "Byte size: %s, Element count: %s", perBundleByteCount, perBundleElementCount);
      }
    }
  
    private static class TimerEndpoint {
  
      private final String pTransformId;
      private final String timerFamilyId;
  
      public TimerEndpoint(String pTransformId, String timerFamilyId) {
        this.pTransformId = pTransformId;
        this.timerFamilyId = timerFamilyId;
      }
  
      @Override
      public boolean equals(Object o) {
        if (this == o) {
          return true;
        }
        if (!(o instanceof TimerEndpoint)) {
          return false;
        }
        TimerEndpoint that = (TimerEndpoint) o;
        return pTransformId.equals(that.pTransformId) && timerFamilyId.equals(that.timerFamilyId);
      }
  
      @Override
      public int hashCode() {
        return Objects.hash(pTransformId, timerFamilyId);
      }
  
      @Override
      public String toString() {
        return "pTransformId: " + pTransformId + " timerFamilyId: " + timerFamilyId;
      }
    }
  }
  