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

com.urbanairship.connect.client.StreamConsumeTask Maven / Gradle / Ivy

The newest version!
/*
Copyright 2015-2022 Airship and Contributors
*/

package com.urbanairship.connect.client;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Supplier;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.urbanairship.connect.client.consume.BackoffConnectionRetryStrategy;
import com.urbanairship.connect.client.consume.ConnectionRetryStrategy;
import com.urbanairship.connect.client.model.GsonUtil;
import com.urbanairship.connect.client.model.StreamQueryDescriptor;
import com.urbanairship.connect.client.model.request.StartPosition;
import com.urbanairship.connect.java8.Consumer;
import org.asynchttpclient.AsyncHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/**
 * A class for handling {@link StreamConnection} interactions and expose the data received from the Airship Real-Time Data Streaming
 * API out through a {@link BlockingQueue} provided by the user. Includes basic stream connection/consumption and
 * reconnection on retryable errors.
 * 

* Proper use of this class requires that only a single call ever be made to the {@link #run()} method. The {@link #stop()} * method can be called by any thread, but should not be called before {@link #run()} is called. *

* StreamConsumeTask objects cannot be reused. */ public final class StreamConsumeTask implements Runnable { private static final Logger log = LoggerFactory.getLogger(StreamConsumeTask.class); private final AsyncHttpClient http; private final StreamQueryDescriptor streamQueryDescriptor; private final Optional initialPosition; private final StreamConnectionSupplier supplier; private final boolean manageHttpLifecycle; private final EnqueuingConsumer consumer; private final AtomicBoolean active = new AtomicBoolean(true); private final CountDownLatch done = new CountDownLatch(1); private final Object streamLock = new Object(); private volatile StreamConnection streamConnection; public static Builder newBuilder() { return new Builder(); } private StreamConsumeTask(AsyncHttpClient client, StreamQueryDescriptor streamQueryDescriptor, BlockingQueue targetQueue, Optional initialPosition, StreamConnectionSupplier supplier, boolean manageHttpLifecycle) { this.http = client; this.streamQueryDescriptor = streamQueryDescriptor; this.initialPosition = initialPosition; this.supplier = supplier; this.manageHttpLifecycle = manageHttpLifecycle; this.consumer = new EnqueuingConsumer(GsonUtil.getGson(), targetQueue); } /** * Begins the process of consuming from the stream by interacting {@link StreamConnection}. The call will block * until {@link #stop()} is called. */ @Override public void run() { try { // can throw a ConnectionException, which extends runtime. log.debug("Starting run"); stream(); } finally { if (manageHttpLifecycle) { try { http.close(); } catch (IOException e) { e.printStackTrace(); } } log.debug("Stopping run"); done.countDown(); } } private void stream() throws ConnectionException { while (active.get()) { Optional position = getPosition(); log.debug("Opening new stream connection at position " + position); try (StreamConnection newStreamConnection = supplier.get(streamQueryDescriptor, http, consumer)) { transitionToReading(position, newStreamConnection); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (ConnectionException e) { throw e; } catch (Throwable throwable) { // we simply log because part of the purpose of this class is to permit the user to ignore network // errors, prematurely truncated buffers, &c. // After this exception is caught, The connection will be re-opened from the last successfully consumed // offset, and processing will continue from there. log.warn("caught exception consuming from connect stream, will resume reading from last successfully consumed event", throwable); } } } private Optional getPosition() { Optional lastOffset = consumer.get(); log.debug("Consumer last offset: " + consumer.lastOffset + ", InitialPosition: " + initialPosition); if (lastOffset.isPresent()) { return Optional.of(StartPosition.offset(lastOffset.get())); } return initialPosition; } private void transitionToReading(Optional position, StreamConnection newStreamConnection) throws InterruptedException, ConnectionException { // The streamLock sync is used to ensure consistency between the stop method and the swap of the streamConnection // resource in the case of a race. We want to ensure we only active and begin reading from the stream if a // stop signal has not been received. Note, it's ok to call StreamConnection.read() even if the StreamConnection // has been closed. Therefore, the possible race where we get in the sync block, swap the stream resource and // between exiting the sync block and the read call, a stop call occurs and closes the stream resource is ok. synchronized (streamLock) { if (!active.get()) { return; } streamConnection = newStreamConnection; } streamConnection.read(position); } /** * Stops the task and causes the {@link #run()} method to exit. */ public void stop() { if (!active.compareAndSet(true, false)) { log.debug("Ignoring call to stop as initial call has already occurred"); return; } log.info("Shutting down stream handler for app " + streamQueryDescriptor.getCreds().getAppKey()); // The streamLock sync is used to guard the potential race between a call to stop and an iteration inside the // stream method. We want to ensure that if the streamConnection resource is setup, we close it. The streamLock // is used on swapping that resource and so we know it cannot change and its state is consistent inside the sync. synchronized (streamLock) { if (streamConnection != null) { try { streamConnection.close(); } catch (Exception e) { throw new RuntimeException("Failed to shutdown stream and stop gracefully", e); } } } } public static final class Builder { private StreamConnectionSupplier supplier = new MobileEventStreamConnectionSupplier(); private StreamQueryDescriptor streamQueryDescriptor = null; private Optional initialPosition = Optional.absent(); private BlockingQueue targetQueue = null; private AsyncHttpClient http = null; private Builder() { } /** * Specify the queue into which received events will be placed upon receipt. * * @param targetQueue BlockingQueue to put received events into. */ public Builder setTargetQueue(BlockingQueue targetQueue) { this.targetQueue = targetQueue; return this; } /** * Specify the parameters for the stream request. * * @param descriptor stream specification */ public Builder setStreamQueryDescriptor(StreamQueryDescriptor descriptor) { this.streamQueryDescriptor = descriptor; return this; } /** * Optionally specify the starting position for stream consumption. * * @param position starting position for consume. */ public Builder setStartingPosition(StartPosition position) { this.initialPosition = Optional.of(position); return this; } /** * Optionally set the http client that will be used for connecting to the API endpoint. If the client is not * specified, the default client specified by {@link HttpClientUtil#defaultHttpClient()} will be used. *

* This is exposed to provide the ability to override the HTTP client settings. In most cases, this is not * necessary. *

* If a client is provided externally by using this method, the task will NOT close it when the task exits * meaning that it is the responsibility of the caller to manage the HTTP client's lifecycle. If no external * HTTP client is specified via this method (and thus the library default client is used) the task will handle * lifecycle management of the client. * * @param http the HTTP client to use for transport */ public Builder setHttpClient(AsyncHttpClient http) { this.http = http; return this; } @VisibleForTesting Builder setStreamConnectionSupplier(StreamConnectionSupplier supplier) { this.supplier = supplier; return this; } public StreamConsumeTask build() { Preconditions.checkNotNull(streamQueryDescriptor, "Stream query descriptor must be provided"); Preconditions.checkNotNull(targetQueue, "Target queue must be provided"); boolean manageHttpLifecycle = false; if (http == null) { http = HttpClientUtil.defaultHttpClient(); manageHttpLifecycle = true; } return new StreamConsumeTask( http, streamQueryDescriptor, targetQueue, initialPosition, supplier, manageHttpLifecycle ); } } private static final ConnectionRetryStrategy CONNECTION_RETRY_STRATEGY = BackoffConnectionRetryStrategy.newBuilder() .setMaxAttempts(10) .setInterval(500L) .setMaxWaitSeconds(30L) .build(); // Default StreamConnectionSupplier implementation private static class MobileEventStreamConnectionSupplier implements StreamConnectionSupplier { @Override public StreamConnection get(StreamQueryDescriptor descriptor, AsyncHttpClient client, Consumer eventConsumer) { return new StreamConnection(descriptor, client, CONNECTION_RETRY_STRATEGY, eventConsumer); } } private final class EnqueuingConsumer implements Consumer, Supplier> { private final AtomicReference lastOffset = new AtomicReference<>(null); private final Gson gson; private final BlockingQueue targetQueue; public EnqueuingConsumer(Gson gson, BlockingQueue targetQueue) { this.gson = gson; this.targetQueue = targetQueue; } @Override public void accept(String event) { String offset = getOffset(event); // Possible that a reconnection reset the stream to our last offset and thus we could get an event we've // seen already since the stream starts at the last recorded offset if (lastOffset.get() != null && lastOffset.get().equals(offset)) { return; } try { while (active.get()) { if (targetQueue.offer(event, 1, TimeUnit.SECONDS)) { lastOffset.set(offset); break; } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private String getOffset(String event) { JsonObject obj = gson.fromJson(event, JsonObject.class); return obj.get("offset").getAsString(); } @Override public Optional get() { return Optional.fromNullable(lastOffset.get()); } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy