All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opentripplanner.ext.siri.updater.azure.SiriAzureUpdater Maven / Gradle / Ivy

The newest version!
package org.opentripplanner.ext.siri.updater.azure;

import com.azure.identity.DefaultAzureCredentialBuilder;
import com.azure.messaging.servicebus.ServiceBusClientBuilder;
import com.azure.messaging.servicebus.ServiceBusErrorContext;
import com.azure.messaging.servicebus.ServiceBusException;
import com.azure.messaging.servicebus.ServiceBusFailureReason;
import com.azure.messaging.servicebus.ServiceBusProcessorClient;
import com.azure.messaging.servicebus.ServiceBusReceivedMessageContext;
import com.azure.messaging.servicebus.administration.ServiceBusAdministrationAsyncClient;
import com.azure.messaging.servicebus.administration.ServiceBusAdministrationClientBuilder;
import com.azure.messaging.servicebus.administration.models.CreateSubscriptionOptions;
import com.azure.messaging.servicebus.models.ServiceBusReceiveMode;
import com.google.common.base.Preconditions;
import jakarta.xml.bind.JAXBException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.Nullable;
import javax.xml.stream.XMLStreamException;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.opentripplanner.framework.application.ApplicationShutdownSupport;
import org.opentripplanner.framework.io.OtpHttpClientException;
import org.opentripplanner.framework.io.OtpHttpClientFactory;
import org.opentripplanner.transit.service.TimetableRepository;
import org.opentripplanner.updater.spi.GraphUpdater;
import org.opentripplanner.updater.spi.HttpHeaders;
import org.opentripplanner.updater.spi.WriteToGraphCallback;
import org.opentripplanner.updater.trip.siri.SiriRealTimeTripUpdateAdapter;
import org.rutebanken.siri20.util.SiriXml;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.org.siri.siri20.ServiceDelivery;
import uk.org.siri.siri20.Siri;

/**
 * This is the main handler for siri messages over azure. It handles the generic code for communicating
 * with the azure service bus and delegates to SiriAzureETUpdater and SiriAzureSXUpdater for ET and
 * SX specific stuff.
 */
public class SiriAzureUpdater implements GraphUpdater {

  /**
   *  custom functional interface that allows throwing checked exceptions, thereby
   *  preserving the exception's intent and type.
   */
  @FunctionalInterface
  interface CheckedRunnable {
    void run() throws Exception;
  }

  private static final Set RETRYABLE_REASONS = Set.of(
    ServiceBusFailureReason.GENERAL_ERROR,
    ServiceBusFailureReason.QUOTA_EXCEEDED,
    ServiceBusFailureReason.SERVICE_BUSY,
    ServiceBusFailureReason.SERVICE_COMMUNICATION_ERROR,
    ServiceBusFailureReason.SERVICE_TIMEOUT,
    ServiceBusFailureReason.UNAUTHORIZED,
    ServiceBusFailureReason.MESSAGE_LOCK_LOST,
    ServiceBusFailureReason.SESSION_LOCK_LOST,
    ServiceBusFailureReason.SESSION_CANNOT_BE_LOCKED
  );

  private static final Set NON_RETRYABLE_REASONS = Set.of(
    ServiceBusFailureReason.MESSAGING_ENTITY_NOT_FOUND,
    ServiceBusFailureReason.MESSAGING_ENTITY_DISABLED,
    ServiceBusFailureReason.MESSAGE_SIZE_EXCEEDED,
    ServiceBusFailureReason.MESSAGE_NOT_FOUND,
    ServiceBusFailureReason.MESSAGING_ENTITY_ALREADY_EXISTS
  );

  private final Logger LOG = LoggerFactory.getLogger(getClass());
  private final String updaterType;
  private final AuthenticationType authenticationType;
  private final String fullyQualifiedNamespace;
  private final String configRef;
  private final String serviceBusUrl;
  private final String topicName;
  private final Duration autoDeleteOnIdle;
  private final int prefetchCount;

  private ServiceBusProcessorClient eventProcessor;
  private ServiceBusAdministrationAsyncClient serviceBusAdmin;
  private boolean isPrimed = false;
  private String subscriptionName;

  private static final AtomicLong MESSAGE_COUNTER = new AtomicLong(0);

  private final SiriAzureMessageHandler messageHandler;

  /**
   * The URL used to fetch all initial updates, null means don't fetch initial data
   */
  @Nullable
  private final URI dataInitializationUrl;

  /**
   * The timeout used when fetching historical data
   */
  private final int timeout;

  SiriAzureUpdater(SiriAzureUpdaterParameters config, SiriAzureMessageHandler messageHandler) {
    this.messageHandler = Objects.requireNonNull(messageHandler);

    try {
      this.dataInitializationUrl = config.buildDataInitializationUrl().orElse(null);
    } catch (URISyntaxException e) {
      throw new IllegalArgumentException("Invalid history url", e);
    }

    this.configRef = Objects.requireNonNull(config.configRef(), "configRef must not be null");
    this.authenticationType = Objects.requireNonNull(
      config.getAuthenticationType(),
      "authenticationType must not be null"
    );
    this.topicName = Objects.requireNonNull(config.getTopicName(), "topicName must not be null");
    this.updaterType = Objects.requireNonNull(config.getType(), "type must not be null");
    this.timeout = config.getTimeout();
    this.autoDeleteOnIdle = config.getAutoDeleteOnIdle();
    this.prefetchCount = config.getPrefetchCount();

    if (authenticationType == AuthenticationType.FederatedIdentity) {
      this.fullyQualifiedNamespace = Objects.requireNonNull(
        config.getFullyQualifiedNamespace(),
        "fullyQualifiedNamespace must not be null when using FederatedIdentity authentication"
      );
      this.serviceBusUrl = null;
    } else if (authenticationType == AuthenticationType.SharedAccessKey) {
      this.serviceBusUrl = Objects.requireNonNull(
        config.getServiceBusUrl(),
        "serviceBusUrl must not be null when using SharedAccessKey authentication"
      );
      this.fullyQualifiedNamespace = null;
    } else {
      throw new IllegalArgumentException("Unsupported authentication type: " + authenticationType);
    }
  }

  public static SiriAzureUpdater createETUpdater(
    SiriAzureETUpdaterParameters config,
    SiriRealTimeTripUpdateAdapter adapter
  ) {
    var messageHandler = new SiriAzureETUpdater(config, adapter);
    return new SiriAzureUpdater(config, messageHandler);
  }

  public static SiriAzureUpdater createSXUpdater(
    SiriAzureSXUpdaterParameters config,
    TimetableRepository timetableRepository
  ) {
    var messageHandler = new SiriAzureSXUpdater(config, timetableRepository);
    return new SiriAzureUpdater(config, messageHandler);
  }

  @Override
  public void setup(WriteToGraphCallback writeToGraphCallback) {
    this.messageHandler.setup(writeToGraphCallback);
  }

  @Override
  public void run() {
    // In Kubernetes this should be the POD identifier
    subscriptionName = System.getenv("HOSTNAME");
    if (subscriptionName == null || subscriptionName.isBlank()) {
      subscriptionName = "otp-" + UUID.randomUUID();
    }

    try {
      executeWithRetry(this::setupSubscription, "Setting up Service Bus subscription to topic");

      executeWithRetry(
        () -> {
          var initialData = fetchInitialSiriData();
          if (initialData.isEmpty()) {
            LOG.info("Got empty response from history endpoint");
          } else {
            processInitialSiriData(initialData.get());
          }
        },
        "Initializing historical Siri data"
      );

      executeWithRetry(this::startEventProcessor, "Starting Service Bus event processor");

      setPrimed();

      ApplicationShutdownSupport.addShutdownHook("azure-siri-updater-shutdown", () -> {
        LOG.info("Calling shutdownHook on AbstractAzureSiriUpdater");
        if (eventProcessor != null) {
          eventProcessor.close();
        }
        if (serviceBusAdmin != null) {
          serviceBusAdmin.deleteSubscription(topicName, subscriptionName).block();
          LOG.info("Subscription '{}' deleted on topic '{}'.", subscriptionName, topicName);
        }
      });
    } catch (ServiceBusException e) {
      LOG.error("Service Bus encountered an error during setup: {}", e.getMessage(), e);
    } catch (URISyntaxException e) {
      LOG.error("Invalid URI provided for Service Bus setup: {}", e.getMessage(), e);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      LOG.warn("Updater was interrupted during setup.");
    } catch (Exception e) {
      LOG.error("An unexpected error occurred during setup: {}", e.getMessage(), e);
    }
  }

  /**
   * Sleeps. This is to be able to mock testing
   * @param millis number of milliseconds
   * @throws InterruptedException if sleep is interrupted
   */
  void sleep(int millis) throws InterruptedException {
    Thread.sleep(millis);
  }

  /**
   * Executes a task with retry logic. Retries indefinitely for retryable exceptions with exponential backoff.
   *  Does not retry for InterruptedException and propagates it
   * @param task The task to execute.
   * @param description A description of the task for logging purposes.
   * @throws InterruptedException If the thread is interrupted while waiting between retries.
   */
  void executeWithRetry(CheckedRunnable task, String description) throws Exception {
    int sleepPeriod = 1000; // Start with 1-second delay
    int attemptCounter = 1;

    while (true) {
      try {
        task.run();
        LOG.info("{} succeeded.", description);
        return;
      } catch (InterruptedException ie) {
        LOG.warn("{} was interrupted during execution.", description);
        Thread.currentThread().interrupt(); // Restore interrupted status
        throw ie;
      } catch (Exception e) {
        LOG.warn("{} failed. Error: {} (Attempt {})", description, e.getMessage(), attemptCounter);

        if (!shouldRetry(e)) {
          LOG.error("{} encountered a non-retryable error: {}.", description, e.getMessage());
          throw e; // Stop retries if the error is non-retryable
        }

        LOG.warn("{} will retry in {} ms.", description, sleepPeriod);
        attemptCounter++;
        try {
          sleep(sleepPeriod);
        } catch (InterruptedException ie) {
          LOG.warn("{} was interrupted during sleep.", description);
          Thread.currentThread().interrupt(); // Restore interrupted status
          throw ie;
        }
        sleepPeriod = Math.min(sleepPeriod * 2, 60 * 1000); // Exponential backoff with a cap at 60 seconds
      }
    }
  }

  boolean shouldRetry(Exception e) {
    if (e instanceof ServiceBusException sbException) {
      ServiceBusFailureReason reason = sbException.getReason();

      if (RETRYABLE_REASONS.contains(reason)) {
        LOG.warn("Transient error encountered: {}. Retrying...", reason);
        return true;
      } else if (NON_RETRYABLE_REASONS.contains(reason)) {
        LOG.error("Non-recoverable error encountered: {}. Not retrying.", reason);
        return false;
      } else {
        LOG.warn("Unhandled ServiceBusFailureReason: {}. Retrying by default.", reason);
        return true;
      }
    } else if (ExceptionUtils.hasCause(e, OtpHttpClientException.class)) {
      // retry for OtpHttpClientException as it is thrown if historical data can't be read at the moment
      return true;
    }

    LOG.warn("Non-ServiceBus exception encountered: {}. Not retrying.", e.getClass().getName());
    return false;
  }

  /**
   * Sets up the Service Bus subscription, including checking old subscription, deleting if necessary,
   * and creating a new subscription.
   */
  private void setupSubscription() throws ServiceBusException, URISyntaxException {
    // Client with permissions to create subscription
    if (authenticationType == AuthenticationType.FederatedIdentity) {
      serviceBusAdmin = new ServiceBusAdministrationClientBuilder()
        .credential(fullyQualifiedNamespace, new DefaultAzureCredentialBuilder().build())
        .buildAsyncClient();
    } else if (authenticationType == AuthenticationType.SharedAccessKey) {
      serviceBusAdmin = new ServiceBusAdministrationClientBuilder()
        .connectionString(serviceBusUrl)
        .buildAsyncClient();
    }

    // Set options
    CreateSubscriptionOptions options = new CreateSubscriptionOptions()
      .setDefaultMessageTimeToLive(Duration.of(25, ChronoUnit.HOURS))
      .setAutoDeleteOnIdle(autoDeleteOnIdle);

    // Make sure there is no old subscription on serviceBus
    if (
      Boolean.TRUE.equals(
        serviceBusAdmin.getSubscriptionExists(topicName, subscriptionName).block()
      )
    ) {
      LOG.info(
        "Subscription '{}' already exists. Deleting existing subscription.",
        subscriptionName
      );
      serviceBusAdmin.deleteSubscription(topicName, subscriptionName).block();
      LOG.info("Service Bus deleted subscription {}.", subscriptionName);
    }
    serviceBusAdmin.createSubscription(topicName, subscriptionName, options).block();

    LOG.info("{} created subscription {}", getClass().getSimpleName(), subscriptionName);
  }

  /**
   * Starts the Service Bus event processor.
   */
  private void startEventProcessor() throws ServiceBusException {
    ServiceBusClientBuilder clientBuilder = new ServiceBusClientBuilder();

    if (authenticationType == AuthenticationType.FederatedIdentity) {
      Preconditions.checkNotNull(
        fullyQualifiedNamespace,
        "fullyQualifiedNamespace must be set for FederatedIdentity authentication"
      );
      clientBuilder
        .fullyQualifiedNamespace(fullyQualifiedNamespace)
        .credential(new DefaultAzureCredentialBuilder().build());
    } else if (authenticationType == AuthenticationType.SharedAccessKey) {
      Preconditions.checkNotNull(
        serviceBusUrl,
        "serviceBusUrl must be set for SharedAccessKey authentication"
      );
      clientBuilder.connectionString(serviceBusUrl);
    } else {
      throw new IllegalArgumentException("Unsupported authentication type: " + authenticationType);
    }

    eventProcessor = clientBuilder
      .processor()
      .topicName(topicName)
      .subscriptionName(subscriptionName)
      .receiveMode(ServiceBusReceiveMode.RECEIVE_AND_DELETE)
      .disableAutoComplete() // Receive and delete does not need autocomplete
      .prefetchCount(prefetchCount)
      .processError(this::errorConsumer)
      .processMessage(this::handleMessage)
      .buildProcessorClient();

    eventProcessor.start();
    LOG.info(
      "Service Bus processor started for topic '{}' and subscription '{}', prefetching {} messages.",
      topicName,
      subscriptionName,
      prefetchCount
    );
  }

  private void handleMessage(ServiceBusReceivedMessageContext messageContext) {
    var message = messageContext.getMessage();
    MESSAGE_COUNTER.incrementAndGet();

    if (MESSAGE_COUNTER.get() % 100 == 0) {
      LOG.debug("Total SIRI-{} messages received={}", updaterType, MESSAGE_COUNTER.get());
    }

    try {
      var siriXmlMessage = message.getBody().toString();
      var siri = SiriXml.parseXml(siriXmlMessage);
      var serviceDelivery = siri.getServiceDelivery();
      if (serviceDelivery == null) {
        if (siri.getHeartbeatNotification() != null) {
          LOG.debug("Updater {} received SIRI heartbeat message", updaterType);
        } else {
          LOG.debug("Updater {} received SIRI message without ServiceDelivery", updaterType);
        }
      } else {
        messageHandler.handleMessage(serviceDelivery, message.getMessageId());
      }
    } catch (JAXBException | XMLStreamException e) {
      LOG.error(e.getLocalizedMessage(), e);
    }
  }

  @Override
  public boolean isPrimed() {
    return this.isPrimed;
  }

  private void setPrimed() {
    isPrimed = true;
  }

  @Override
  public String getConfigRef() {
    return this.configRef;
  }

  /**
   * Returns None for empty result
   */
  private Optional fetchInitialSiriData() {
    if (dataInitializationUrl == null) {
      return Optional.empty();
    }
    var headers = HttpHeaders.of().acceptApplicationXML().build().asMap();

    LOG.info(
      "Fetching initial Siri data from {}, timeout is {} ms.",
      this.dataInitializationUrl,
      timeout
    );

    try (OtpHttpClientFactory otpHttpClientFactory = new OtpHttpClientFactory()) {
      var otpHttpClient = otpHttpClientFactory.create(LOG);
      var t1 = System.currentTimeMillis();
      var siriOptional = otpHttpClient.executeAndMapOptional(
        new HttpGet(dataInitializationUrl),
        Duration.ofMillis(timeout),
        headers,
        SiriXml::parseXml
      );
      var t2 = System.currentTimeMillis();
      LOG.info("Fetched initial data in {} ms", (t2 - t1));

      if (siriOptional.isEmpty()) {
        LOG.info("Got status 204 'No Content'.");
      }

      return siriOptional.map(Siri::getServiceDelivery);
    }
  }

  public void processInitialSiriData(ServiceDelivery serviceDelivery) {
    try {
      long t1 = System.currentTimeMillis();
      var f = messageHandler.handleMessage(serviceDelivery, "history-message");
      if (f != null) {
        f.get();
      }
      LOG.info("{} updater initialized in {} ms.", updaterType, (System.currentTimeMillis() - t1));
    } catch (ExecutionException | InterruptedException e) {
      throw new SiriAzureInitializationException("Error applying history", e);
    }
  }

  /**
   * Make some sensible logging on error and if Service Bus is busy, sleep for some time before try again to get messages.
   * This code snippet is taken from Microsoft example ....
   * @param errorContext Context for errors handled by the ServiceBusProcessorClient.
   */
  private void errorConsumer(ServiceBusErrorContext errorContext) {
    LOG.error(
      "Error when receiving messages from namespace={}, Entity={}",
      errorContext.getFullyQualifiedNamespace(),
      errorContext.getEntityPath()
    );

    if (!(errorContext.getException() instanceof ServiceBusException e)) {
      LOG.error("Non-ServiceBusException occurred!", errorContext.getException());
      return;
    }

    var reason = e.getReason();

    if (
      reason == ServiceBusFailureReason.MESSAGING_ENTITY_DISABLED ||
      reason == ServiceBusFailureReason.MESSAGING_ENTITY_NOT_FOUND // should this  be recoverable?
    ) {
      LOG.error(
        "An unrecoverable error occurred. Stopping processing with reason {} {}",
        reason,
        e.getMessage()
      );
    } else if (reason == ServiceBusFailureReason.MESSAGE_LOCK_LOST) {
      LOG.error("Message lock lost for message", e);
    } else if (
      reason == ServiceBusFailureReason.SERVICE_BUSY ||
      reason == ServiceBusFailureReason.UNAUTHORIZED
    ) {
      LOG.error("Service Bus is busy or unauthorized, wait and try again");
      try {
        // Choosing an arbitrary amount of time to wait until trying again.
        TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException ie) {
        Thread.currentThread().interrupt();
        LOG.info("OTP is shutting down, stopping processing of ServiceBus error messages");
      }
    } else {
      LOG.error(e.getLocalizedMessage(), e);
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy