org.eclipse.milo.opcua.sdk.server.subscriptions.Subscription.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.milo.opcua.sdk.server.subscriptions.Subscription.java

Source

/*
 * Copyright (c) 2019 the Eclipse Milo Authors
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */

package org.eclipse.milo.opcua.sdk.server.subscriptions;

import java.math.RoundingMode;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

import com.google.common.collect.Iterators;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.PeekingIterator;
import com.google.common.math.DoubleMath;
import com.google.common.primitives.Ints;
import org.eclipse.milo.opcua.sdk.server.Session;
import org.eclipse.milo.opcua.sdk.server.api.config.OpcUaServerConfigLimits;
import org.eclipse.milo.opcua.sdk.server.items.BaseMonitoredItem;
import org.eclipse.milo.opcua.stack.core.StatusCodes;
import org.eclipse.milo.opcua.stack.core.serialization.UaStructure;
import org.eclipse.milo.opcua.stack.core.types.OpcUaDataTypeManager;
import org.eclipse.milo.opcua.stack.core.types.OpcUaDefaultBinaryEncoding;
import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
import org.eclipse.milo.opcua.stack.core.types.builtin.DiagnosticInfo;
import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject;
import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
import org.eclipse.milo.opcua.stack.core.types.structured.DataChangeNotification;
import org.eclipse.milo.opcua.stack.core.types.structured.EventFieldList;
import org.eclipse.milo.opcua.stack.core.types.structured.EventNotificationList;
import org.eclipse.milo.opcua.stack.core.types.structured.ModifySubscriptionRequest;
import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemNotification;
import org.eclipse.milo.opcua.stack.core.types.structured.NotificationMessage;
import org.eclipse.milo.opcua.stack.core.types.structured.PublishRequest;
import org.eclipse.milo.opcua.stack.core.types.structured.PublishResponse;
import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
import org.eclipse.milo.opcua.stack.core.types.structured.SetPublishingModeRequest;
import org.eclipse.milo.opcua.stack.core.types.structured.StatusChangeNotification;
import org.eclipse.milo.opcua.stack.server.services.ServiceRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;

public class Subscription {

    private static final int MAX_NOTIFICATIONS = 0xFFFF;

    private final Logger logger = LoggerFactory.getLogger(getClass());

    private volatile Iterator<BaseMonitoredItem<?>> lastIterator = Collections.emptyIterator();

    private final AtomicLong itemIds = new AtomicLong(1L);
    private final Map<UInteger, BaseMonitoredItem<?>> itemsById = Maps.newConcurrentMap();

    private final AtomicReference<State> state = new AtomicReference<>(State.Normal);
    private final AtomicReference<StateListener> stateListener = new AtomicReference<>();

    private final AtomicLong sequenceNumber = new AtomicLong(1L);

    private final Map<UInteger, NotificationMessage> availableMessages = Maps.newConcurrentMap();

    private final PublishHandler publishHandler = new PublishHandler();
    private final TimerHandler timerHandler = new TimerHandler();

    private volatile ScheduledFuture<?> publishingTimer;

    private volatile boolean messageSent = false;
    private volatile boolean moreNotifications = false;
    private volatile long keepAliveCounter;
    private volatile long lifetimeCounter;

    private volatile double publishingInterval;
    private volatile long lifetimeCount;
    private volatile long maxKeepAliveCount;
    private volatile int maxNotificationsPerPublish;
    private volatile boolean publishingEnabled;
    private volatile int priority;

    private volatile SubscriptionManager subscriptionManager;

    private final UInteger subscriptionId;

    public Subscription(SubscriptionManager subscriptionManager, UInteger subscriptionId, double publishingInterval,
            long maxKeepAliveCount, long lifetimeCount, long maxNotificationsPerPublish, boolean publishingEnabled,
            int priority) {

        this.subscriptionManager = subscriptionManager;
        this.subscriptionId = subscriptionId;

        setPublishingInterval(publishingInterval);
        setMaxKeepAliveCount(maxKeepAliveCount);
        setLifetimeCount(lifetimeCount);
        setMaxNotificationsPerPublish(maxNotificationsPerPublish);

        this.publishingEnabled = publishingEnabled;
        this.priority = priority;

        resetKeepAliveCounter();
        resetLifetimeCounter();

        logger.debug("[id={}] subscription created, interval={}, keep-alive={}, lifetime={}", subscriptionId,
                publishingInterval, maxKeepAliveCount, lifetimeCount);
    }

    public synchronized void modifySubscription(ModifySubscriptionRequest request) {
        setPublishingInterval(request.getRequestedPublishingInterval());
        setMaxKeepAliveCount(request.getRequestedMaxKeepAliveCount().longValue());
        setLifetimeCount(request.getRequestedLifetimeCount().longValue());
        setMaxNotificationsPerPublish(request.getMaxNotificationsPerPublish().longValue());

        this.priority = request.getPriority().intValue();

        resetLifetimeCounter();

        logger.debug("[id={}] subscription modified, interval={}, keep-alive={}, lifetime={}", subscriptionId,
                publishingInterval, maxKeepAliveCount, lifetimeCount);
    }

    public synchronized List<BaseMonitoredItem<?>> deleteSubscription() {
        setState(State.Closed);

        ScheduledFuture<?> sf = publishingTimer;
        if (sf != null)
            sf.cancel(false);
        publishingTimer = null;

        logger.debug("[id={}] subscription deleted.", subscriptionId);

        return Lists.newArrayList(itemsById.values());
    }

    public synchronized void setPublishingMode(SetPublishingModeRequest request) {
        this.publishingEnabled = request.getPublishingEnabled();

        resetLifetimeCounter();

        logger.debug("[id={}] {}.", subscriptionId,
                publishingEnabled ? "publishing enabled." : "publishing disabled.");
    }

    public synchronized void addMonitoredItems(List<BaseMonitoredItem<?>> createdItems) {
        for (BaseMonitoredItem<?> item : createdItems) {
            itemsById.put(item.getId(), item);
        }

        resetLifetimeCounter();

        logger.debug("[id={}] created {} MonitoredItems.", subscriptionId, createdItems.size());
    }

    public synchronized void removeMonitoredItems(List<BaseMonitoredItem<?>> deletedItems) {
        for (BaseMonitoredItem<?> item : deletedItems) {
            itemsById.remove(item.getId());
        }

        resetLifetimeCounter();

        logger.debug("[id={}] deleted {} MonitoredItems.", subscriptionId, deletedItems.size());
    }

    public synchronized Map<UInteger, BaseMonitoredItem<?>> getMonitoredItems() {
        return itemsById;
    }

    /**
     * Given the requested publishing interval, set it to something reasonable.
     *
     * @param requestedPublishingInterval the requested publishing interval.
     */
    private void setPublishingInterval(double requestedPublishingInterval) {
        OpcUaServerConfigLimits limits = subscriptionManager.getServer().getConfig().getLimits();

        double minPublishingInterval = limits.getMinPublishingInterval();
        double maxPublishingInterval = limits.getMaxPublishingInterval();

        if (requestedPublishingInterval < minPublishingInterval || Double.isNaN(requestedPublishingInterval)
                || Double.isInfinite(requestedPublishingInterval)) {

            requestedPublishingInterval = limits.getDefaultPublishingInterval();
        }

        if (requestedPublishingInterval > maxPublishingInterval) {
            requestedPublishingInterval = maxPublishingInterval;
        }

        this.publishingInterval = requestedPublishingInterval;
    }

    private void setMaxKeepAliveCount(long maxKeepAliveCount) {
        OpcUaServerConfigLimits limits = subscriptionManager.getServer().getConfig().getLimits();

        if (maxKeepAliveCount == 0)
            maxKeepAliveCount = 3;

        double keepAliveInterval = maxKeepAliveCount * publishingInterval;

        // keep alive interval cannot be longer than the max subscription lifetime.
        double maxSubscriptionLifetime = limits.getMaxSubscriptionLifetime();

        if (keepAliveInterval > maxSubscriptionLifetime) {
            maxKeepAliveCount = (long) (maxSubscriptionLifetime / publishingInterval);

            if (maxKeepAliveCount < UInteger.MAX_VALUE) {
                if (maxSubscriptionLifetime % publishingInterval != 0) {
                    maxKeepAliveCount++;
                }
            }

            keepAliveInterval = maxKeepAliveCount * publishingInterval;
        }

        // the time between publishes cannot exceed the max publishing interval.
        double maxPublishingInterval = limits.getMaxPublishingInterval();

        if (keepAliveInterval > maxPublishingInterval) {
            maxKeepAliveCount = (long) (maxPublishingInterval / publishingInterval);

            if (maxKeepAliveCount < UInteger.MAX_VALUE) {
                if (maxPublishingInterval % publishingInterval != 0) {
                    maxKeepAliveCount++;
                }
            }
        }

        this.maxKeepAliveCount = maxKeepAliveCount;
    }

    private void setLifetimeCount(long lifetimeCount) {
        OpcUaServerConfigLimits limits = subscriptionManager.getServer().getConfig().getLimits();

        double lifetimeInterval = lifetimeCount * publishingInterval;

        // lifetime cannot be longer than the max subscription lifetime.
        double maxSubscriptionLifetime = limits.getMaxSubscriptionLifetime();

        if (lifetimeInterval > maxSubscriptionLifetime) {
            lifetimeCount = (long) (maxSubscriptionLifetime / publishingInterval);

            if (lifetimeCount < UInteger.MAX_VALUE) {
                if (maxSubscriptionLifetime % publishingInterval != 0) {
                    lifetimeCount++;
                }
            }
        }

        // the lifetime must be greater than the keepalive.
        if (maxKeepAliveCount < UInteger.MAX_VALUE / 3) {
            if (maxKeepAliveCount * 3 > lifetimeCount) {
                lifetimeCount = maxKeepAliveCount * 3;
            }

            lifetimeInterval = lifetimeCount * publishingInterval;
        } else {
            lifetimeCount = UInteger.MAX_VALUE;
            lifetimeInterval = Double.MAX_VALUE;
        }

        // apply the minimum.
        double minSubscriptionLifetime = limits.getMinSubscriptionLifetime();

        if (minSubscriptionLifetime > publishingInterval && minSubscriptionLifetime > lifetimeInterval) {
            lifetimeCount = (long) (minSubscriptionLifetime / publishingInterval);

            if (lifetimeCount < UInteger.MAX_VALUE) {
                if (minSubscriptionLifetime % publishingInterval != 0) {
                    lifetimeCount++;
                }
            }
        }

        this.lifetimeCount = lifetimeCount;
    }

    private void setMaxNotificationsPerPublish(long maxNotificationsPerPublish) {
        if (maxNotificationsPerPublish <= 0 || maxNotificationsPerPublish > MAX_NOTIFICATIONS) {
            maxNotificationsPerPublish = MAX_NOTIFICATIONS;
        }
        this.maxNotificationsPerPublish = Ints.saturatedCast(maxNotificationsPerPublish);
    }

    private synchronized PublishQueue publishQueue() {
        return subscriptionManager.getPublishQueue();
    }

    private long currentSequenceNumber() {
        return sequenceNumber.get();
    }

    private long nextSequenceNumber() {
        return sequenceNumber.getAndIncrement();
    }

    void resetLifetimeCounter() {
        lifetimeCounter = lifetimeCount;

        logger.debug("[id={}] lifetime counter reset to {}", subscriptionId, lifetimeCounter);
    }

    private void resetKeepAliveCounter() {
        keepAliveCounter = maxKeepAliveCount;

        logger.debug("[id={}] keep-alive counter reset to {}", subscriptionId, maxKeepAliveCount);
    }

    private void returnKeepAlive(ServiceRequest service) {
        ResponseHeader header = service.createResponseHeader();

        UInteger sequenceNumber = uint(currentSequenceNumber());

        NotificationMessage notificationMessage = new NotificationMessage(sequenceNumber, DateTime.now(),
                new ExtensionObject[0]);

        UInteger[] available = getAvailableSequenceNumbers();

        UInteger requestHandle = service.getRequest().getRequestHeader().getRequestHandle();
        StatusCode[] acknowledgeResults = subscriptionManager.getAcknowledgeResults(requestHandle);

        PublishResponse response = new PublishResponse(header, subscriptionId, available, moreNotifications,
                notificationMessage, acknowledgeResults, new DiagnosticInfo[0]);

        service.setResponse(response);

        logger.debug("[id={}] returned keep-alive NotificationMessage sequenceNumber={}.", subscriptionId,
                sequenceNumber);
    }

    void returnStatusChangeNotification(ServiceRequest service) {
        StatusChangeNotification statusChange = new StatusChangeNotification(
                new StatusCode(StatusCodes.Bad_Timeout), null);

        UInteger sequenceNumber = uint(nextSequenceNumber());

        NotificationMessage notificationMessage = new NotificationMessage(sequenceNumber, new DateTime(),
                new ExtensionObject[] { ExtensionObject.encode(statusChange) });

        ResponseHeader header = service.createResponseHeader();

        PublishResponse response = new PublishResponse(header, subscriptionId, new UInteger[0], false,
                notificationMessage, new StatusCode[0], new DiagnosticInfo[0]);

        service.setResponse(response);

        logger.debug("[id={}] returned StatusChangeNotification sequenceNumber={}.", subscriptionId,
                sequenceNumber);
    }

    private void returnNotifications(ServiceRequest service) {
        LinkedHashSet<BaseMonitoredItem<?>> items = new LinkedHashSet<>();

        lastIterator.forEachRemaining(items::add);

        itemsById.values().stream().filter(item -> item.hasNotifications() || item.isTriggered())
                .forEach(items::add);

        PeekingIterator<BaseMonitoredItem<?>> iterator = Iterators.peekingIterator(items.iterator());

        gatherAndSend(iterator, service);

        lastIterator = iterator.hasNext() ? iterator : Collections.emptyIterator();
    }

    /**
     * Gather {@link MonitoredItemNotification}s and send them using {@code service}.
     *
     * @param iterator a {@link PeekingIterator} over the current {@link BaseMonitoredItem}s.
     * @param service  a {@link ServiceRequest}.
     */
    private void gatherAndSend(PeekingIterator<BaseMonitoredItem<?>> iterator, ServiceRequest service) {

        List<UaStructure> notifications = Lists.newArrayList();

        while (notifications.size() < maxNotificationsPerPublish && iterator.hasNext()) {
            BaseMonitoredItem<?> item = iterator.peek();

            boolean gatheredAllForItem = gather(item, notifications, maxNotificationsPerPublish);

            if (gatheredAllForItem) {
                iterator.next();
            } else {
                // Not being able to gather all notifications for an item implies that
                // notifications.size() is no longer < maxNotificationsPerPublish, but
                // force a break from the loop just in case...
                break;
            }
        }

        moreNotifications = iterator.hasNext();

        sendNotifications(service, notifications);

        if (moreNotifications) {
            ServiceRequest nextService = publishQueue().poll();

            if (nextService != null) {
                gatherAndSend(iterator, nextService);
            } else {
                publishQueue().addSubscription(this);
            }
        }
    }

    private boolean gather(BaseMonitoredItem<?> item, List<UaStructure> notifications, int maxNotifications) {
        int max = maxNotifications - notifications.size();

        return item.getNotifications(notifications, max);
    }

    private void sendNotifications(ServiceRequest service, List<UaStructure> notifications) {
        List<MonitoredItemNotification> dataNotifications = Lists.newArrayList();
        List<EventFieldList> eventNotifications = Lists.newArrayList();

        notifications.forEach(notification -> {
            if (notification instanceof MonitoredItemNotification) {
                dataNotifications.add((MonitoredItemNotification) notification);
            } else if (notification instanceof EventFieldList) {
                eventNotifications.add((EventFieldList) notification);
            }
        });

        List<ExtensionObject> notificationData = Lists.newArrayList();

        if (dataNotifications.size() > 0) {
            DataChangeNotification dataChange = new DataChangeNotification(
                    dataNotifications.toArray(new MonitoredItemNotification[0]), new DiagnosticInfo[0]);

            notificationData.add(ExtensionObject.encode(dataChange, dataChange.getBinaryEncodingId(),
                    OpcUaDefaultBinaryEncoding.getInstance(),
                    subscriptionManager.getServer().getConfig().getEncodingLimits(),
                    OpcUaDataTypeManager.getInstance()));
        }

        if (eventNotifications.size() > 0) {
            EventNotificationList eventChange = new EventNotificationList(
                    eventNotifications.toArray(new EventFieldList[0]));

            notificationData.add(ExtensionObject.encode(eventChange, eventChange.getBinaryEncodingId(),
                    OpcUaDefaultBinaryEncoding.getInstance(),
                    subscriptionManager.getServer().getConfig().getEncodingLimits(),
                    OpcUaDataTypeManager.getInstance()));
        }

        UInteger sequenceNumber = uint(nextSequenceNumber());

        NotificationMessage notificationMessage = new NotificationMessage(sequenceNumber, new DateTime(),
                notificationData.toArray(new ExtensionObject[0]));

        availableMessages.put(notificationMessage.getSequenceNumber(), notificationMessage);
        UInteger[] available = getAvailableSequenceNumbers();

        UInteger requestHandle = service.getRequest().getRequestHeader().getRequestHandle();
        StatusCode[] acknowledgeResults = subscriptionManager.getAcknowledgeResults(requestHandle);

        ResponseHeader header = service.createResponseHeader();

        PublishResponse response = new PublishResponse(header, subscriptionId, available, moreNotifications,
                notificationMessage, acknowledgeResults, new DiagnosticInfo[0]);

        service.setResponse(response);

        logger.debug(
                "[id={}] returning {} DataChangeNotification(s) and "
                        + "{} EventNotificationList(s) sequenceNumber={} moreNotifications={}.",
                subscriptionId, dataNotifications.size(), eventNotifications.size(), sequenceNumber,
                moreNotifications);
    }

    private boolean notificationsAvailable() {
        return itemsById.values().stream().anyMatch(item -> item.hasNotifications() || item.isTriggered());
    }

    private void setState(State state) {
        State previousState = this.state.getAndSet(state);

        logger.debug("[id={}] {} -> {}", subscriptionId, previousState, state);

        StateListener listener = stateListener.get();

        if (listener != null) {
            listener.onStateChange(this, previousState, state);
        }
    }

    public UInteger getId() {
        return subscriptionId;
    }

    public double getPublishingInterval() {
        return publishingInterval;
    }

    public long getMaxKeepAliveCount() {
        return maxKeepAliveCount;
    }

    public long getLifetimeCount() {
        return lifetimeCount;
    }

    public int getMaxNotificationsPerPublish() {
        return maxNotificationsPerPublish;
    }

    public boolean isPublishingEnabled() {
        return publishingEnabled;
    }

    public int getPriority() {
        return priority;
    }

    public synchronized UInteger[] getAvailableSequenceNumbers() {
        Set<UInteger> uIntegers = availableMessages.keySet();
        UInteger[] available = uIntegers.toArray(new UInteger[0]);
        Arrays.sort(available);
        return available;
    }

    public synchronized SubscriptionManager getSubscriptionManager() {
        return subscriptionManager;
    }

    public synchronized void setSubscriptionManager(SubscriptionManager subscriptionManager) {
        this.subscriptionManager = subscriptionManager;
    }

    public Session getSession() {
        return subscriptionManager.getSession();
    }

    public long nextItemId() {
        return itemIds.getAndIncrement();
    }

    public void setStateListener(StateListener listener) {
        stateListener.set(listener);
    }

    /**
     * Handle an incoming {@link PublishRequest}.
     *
     * @param service The service request that contains the {@link PublishRequest}.
     */
    synchronized void onPublish(ServiceRequest service) {
        State state = this.state.get();

        if (logger.isTraceEnabled()) {
            logger.trace("[id={}] onPublish(), state={}, keep-alive={}, lifetime={}", subscriptionId, state,
                    keepAliveCounter, lifetimeCounter);
        }

        if (state == State.Normal) {
            publishHandler.whenNormal(service);
        } else if (state == State.KeepAlive) {
            publishHandler.whenKeepAlive(service);
        } else if (state == State.Late) {
            publishHandler.whenLate(service);
        } else if (state == State.Closing) {
            publishHandler.whenClosing(service);
        } else if (state == State.Closed) {
            publishHandler.whenClosed(service);
        } else {
            throw new RuntimeException("Unhandled subscription state: " + state);
        }
    }

    /**
     * The publishing timer has elapsed.
     */
    synchronized void onPublishingTimer() {
        State state = this.state.get();

        if (logger.isTraceEnabled()) {
            logger.trace("[id={}] onPublishingTimer(), state={}, keep-alive={}, lifetime={}", subscriptionId, state,
                    keepAliveCounter, lifetimeCounter);
        }

        long startNanos = System.nanoTime();

        if (state == State.Normal) {
            timerHandler.whenNormal();
        } else if (state == State.KeepAlive) {
            timerHandler.whenKeepAlive();
        } else if (state == State.Late) {
            timerHandler.whenLate();
        } else if (state == State.Closed) {
            logger.debug("[id={}] onPublish(), state={}", subscriptionId, state); // No-op.
        } else {
            throw new RuntimeException("unhandled subscription state: " + state);
        }

        long elapsedNanos = System.nanoTime() - startNanos;

        long intervalNanos = TimeUnit.NANOSECONDS
                .convert(DoubleMath.roundToLong(publishingInterval, RoundingMode.UP), TimeUnit.MILLISECONDS);

        long adjustedIntervalNanos = Math.max(0, intervalNanos - elapsedNanos);

        startPublishingTimer(adjustedIntervalNanos);
    }

    synchronized void startPublishingTimer() {
        long intervalNanos = TimeUnit.NANOSECONDS
                .convert(DoubleMath.roundToLong(publishingInterval, RoundingMode.UP), TimeUnit.MILLISECONDS);

        startPublishingTimer(intervalNanos);
    }

    private synchronized void startPublishingTimer(long delayNanos) {
        if (state.get() == State.Closed)
            return;

        // lifetimeCounter is always accessed while synchronized on 'this'.
        lifetimeCounter = lifetimeCounter - 1;

        if (lifetimeCounter < 1) {
            logger.debug("[id={}] lifetime expired.", subscriptionId);

            setState(State.Closing);
        } else {
            publishingTimer = subscriptionManager.getServer().getScheduledExecutorService()
                    .schedule(this::onPublishingTimer, delayNanos, TimeUnit.NANOSECONDS);
        }
    }

    public synchronized StatusCode acknowledge(UInteger sequenceNumber) {
        if (availableMessages.remove(sequenceNumber) != null) {
            logger.debug("[id={}] sequence number acknowledged: {}", subscriptionId, sequenceNumber);

            return StatusCode.GOOD;
        } else {
            logger.debug("[id={}] sequence number unknown: {}", subscriptionId, sequenceNumber);

            return new StatusCode(StatusCodes.Bad_SequenceNumberUnknown);
        }
    }

    public synchronized NotificationMessage republish(UInteger sequenceNumber) {
        resetLifetimeCounter();

        return availableMessages.get(sequenceNumber);
    }

    private class PublishHandler {
        private void whenNormal(ServiceRequest service) {
            boolean publishingEnabled = Subscription.this.publishingEnabled;

            if (!publishingEnabled || (publishingEnabled && !moreNotifications)) {
                /* Subscription State Table Row 4 */
                publishQueue().addRequest(service);
            } else if (publishingEnabled && moreNotifications) {
                /* Subscription State Table Row 5 */
                resetLifetimeCounter();
                resetKeepAliveCounter();
                messageSent = true;
                returnNotifications(service);
            } else {
                throw new IllegalStateException("unhandled subscription state");
            }
        }

        private void whenLate(ServiceRequest service) {
            boolean publishingEnabled = Subscription.this.publishingEnabled;
            boolean notificationsAvailable = notificationsAvailable();

            if (publishingEnabled && (notificationsAvailable || moreNotifications)) {
                /* Subscription State Table Row 10 */
                setState(State.Normal);
                resetLifetimeCounter();
                resetKeepAliveCounter();
                messageSent = true;
                returnNotifications(service);
            } else if (!publishingEnabled || (publishingEnabled && !notificationsAvailable && !moreNotifications)) {
                /* Subscription State Table Row 11 */
                setState(State.KeepAlive);
                resetLifetimeCounter();
                resetKeepAliveCounter();
                messageSent = true;
                returnKeepAlive(service);
            } else {
                throw new IllegalStateException("unhandled subscription state");
            }
        }

        private void whenKeepAlive(ServiceRequest service) {
            /* Subscription State Table Row 13 */
            publishQueue().addRequest(service);
        }

        private void whenClosing(ServiceRequest service) {
            returnStatusChangeNotification(service);

            setState(State.Closed);
        }

        private void whenClosed(ServiceRequest service) {
            publishQueue().addRequest(service);
        }
    }

    private class TimerHandler {
        private void whenNormal() {
            boolean publishRequestQueued = publishQueue().isNotEmpty();
            boolean publishingEnabled = Subscription.this.publishingEnabled;
            boolean notificationsAvailable = notificationsAvailable();

            if (publishRequestQueued && publishingEnabled && notificationsAvailable) {
                /* Subscription State Table Row 6 */
                ServiceRequest service = publishQueue().poll();

                if (service != null) {
                    resetLifetimeCounter();
                    resetKeepAliveCounter();
                    messageSent = true;
                    returnNotifications(service);
                } else {
                    whenNormal();
                }
            } else if (publishRequestQueued && !messageSent
                    && (!publishingEnabled || (publishingEnabled && !notificationsAvailable))) {
                /* Subscription State Table Row 7 */
                ServiceRequest service = publishQueue().poll();

                if (service != null) {
                    resetLifetimeCounter();
                    resetKeepAliveCounter();
                    messageSent = true;
                    returnKeepAlive(service);
                } else {
                    whenNormal();
                }
            } else if (!publishRequestQueued && (!messageSent || (publishingEnabled && notificationsAvailable))) {
                /* Subscription State Table Row 8 */
                setState(State.Late);

                publishQueue().addSubscription(Subscription.this);
            } else if (messageSent && (!publishingEnabled || (publishingEnabled && !notificationsAvailable))) {
                /* Subscription State Table Row 9 */
                setState(State.KeepAlive);
                resetKeepAliveCounter();
            } else {
                throw new IllegalStateException("unhandled subscription state");
            }
        }

        private void whenLate() {
            /* Subscription State Table Row 12 */

            // NO-OP; publishing timer will be started after this method returns.
        }

        private void whenKeepAlive() {
            boolean publishingEnabled = Subscription.this.publishingEnabled;
            boolean notificationsAvailable = notificationsAvailable();
            boolean publishRequestQueued = publishQueue().isNotEmpty();

            if (publishingEnabled && notificationsAvailable && publishRequestQueued) {
                /* Subscription State Table Row 14 */
                ServiceRequest service = publishQueue().poll();

                if (service != null) {
                    setState(State.Normal);
                    resetLifetimeCounter();
                    resetKeepAliveCounter();
                    messageSent = true;
                    returnNotifications(service);
                } else {
                    whenKeepAlive();
                }
            } else if (publishRequestQueued && keepAliveCounter == 1
                    && (!publishingEnabled || (publishingEnabled && !notificationsAvailable))) {
                /* Subscription State Table Row 15 */

                ServiceRequest service = publishQueue().poll();

                if (service != null) {
                    resetLifetimeCounter();
                    resetKeepAliveCounter();
                    returnKeepAlive(service);
                } else {
                    whenKeepAlive();
                }
            } else if (keepAliveCounter > 1
                    && (!publishingEnabled || (publishingEnabled && !notificationsAvailable))) {
                /* Subscription State Table Row 16 */

                keepAliveCounter--;
            } else if (!publishRequestQueued && (keepAliveCounter == 1
                    || (keepAliveCounter > 1 && publishingEnabled && notificationsAvailable))) {
                /* Subscription State Table Row 17 */

                setState(State.Late);

                publishQueue().addSubscription(Subscription.this);
            }
        }
    }

    public enum State {
        Closing, Closed, Normal, KeepAlive, Late
    }

    public interface StateListener {
        void onStateChange(Subscription subscription, State previousState, State currentState);
    }

}