org.ulyssis.ipp.processor.Processor.java Source code

Java tutorial

Introduction

Here is the source code for org.ulyssis.ipp.processor.Processor.java

Source

/*
 * Copyright (C) 2014-2015 ULYSSIS VZW
 *
 * This file is part of i++.
 * 
 * i++ is free software: you can redistribute it and/or modify
 * it under the terms of version 3 of the GNU Affero General Public License
 * as published by the Free Software Foundation. No other versions apply.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>
 */
package org.ulyssis.ipp.processor;

import com.fasterxml.jackson.core.JsonProcessingException;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.ulyssis.ipp.config.Config;
import org.ulyssis.ipp.config.ReaderConfig;
import org.ulyssis.ipp.config.Team;
import org.ulyssis.ipp.control.CommandProcessor;
import org.ulyssis.ipp.control.commands.AddTagCommand;
import org.ulyssis.ipp.control.commands.CorrectionCommand;
import org.ulyssis.ipp.control.commands.RemoveTagCommand;
import org.ulyssis.ipp.control.commands.SetEndTimeCommand;
import org.ulyssis.ipp.control.commands.SetStartTimeCommand;
import org.ulyssis.ipp.control.commands.SetStatusCommand;
import org.ulyssis.ipp.control.commands.SetStatusMessageCommand;
import org.ulyssis.ipp.control.commands.SetUpdateFrequencyCommand;
import org.ulyssis.ipp.control.handlers.EventCommandHandler;
import org.ulyssis.ipp.control.handlers.PingHandler;
import org.ulyssis.ipp.snapshot.Snapshot;
import org.ulyssis.ipp.snapshot.AddTagEvent;
import org.ulyssis.ipp.snapshot.CorrectionEvent;
import org.ulyssis.ipp.snapshot.EndEvent;
import org.ulyssis.ipp.snapshot.Event;
import org.ulyssis.ipp.snapshot.MessageEvent;
import org.ulyssis.ipp.snapshot.RemoveTagEvent;
import org.ulyssis.ipp.snapshot.StartEvent;
import org.ulyssis.ipp.snapshot.StatusChangeEvent;
import org.ulyssis.ipp.snapshot.UpdateFrequencyChangeEvent;
import org.ulyssis.ipp.status.StatusMessage;
import org.ulyssis.ipp.status.StatusReporter;
import org.ulyssis.ipp.utils.JedisHelper;
import org.ulyssis.ipp.utils.Serialization;
import org.ulyssis.ipp.TagId;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.exceptions.JedisConnectionException;

import java.io.IOException;
import java.net.URI;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import static org.ulyssis.ipp.processor.Database.ConnectionFlags.READ_ONLY;
import static org.ulyssis.ipp.processor.Database.ConnectionFlags.READ_WRITE;

/**
 * The processor, well, pulls and processes the updates that come in from the readers.
 *
 * It maintains the state of the system: the number of laps run for each team, their
 * forecasts, etc.
 */
public final class Processor implements Runnable {
    private static final Logger LOG = LogManager.getLogger(Processor.class);

    private final BlockingQueue<Event> eventQueue;
    private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();

    private final ConcurrentMap<Event, Consumer<Boolean>> eventCallbacks;

    private final StatusReporter statusReporter;
    private final CommandProcessor commandProcessor;
    private final List<Consumer<Processor>> onStartedCallbacks;

    /**
     * The listeners responsible for fetching results from all registered readers
     */
    private final List<ReaderListener> readerListeners;

    private final List<Thread> threads;

    private Snapshot snapshot;

    public Processor(final ProcessorOptions options) {
        Database.setDatabaseURI(options.getDatabaseUri());
        try (Connection connection = Database.createConnection(EnumSet.of(READ_WRITE))) {
            if (options.shouldClearDb()) {
                Database.clearDb(connection);
            }
            Database.initDb(connection);
            connection.commit();
        } catch (SQLException e) {
            LOG.fatal("Error initializing database!", e);
        }
        URI uri = options.getRedisUri();
        this.eventQueue = new LinkedBlockingQueue<>();
        this.eventCallbacks = new ConcurrentHashMap<>();
        this.onStartedCallbacks = new CopyOnWriteArrayList<>();
        this.readerListeners = new ArrayList<>();
        this.threads = new ArrayList<>();
        // TODO: Move status reporting and processing of commands to ZeroMQ?
        // Also: post some stuff to a log in the db?
        this.statusReporter = new StatusReporter(uri, Config.getCurrentConfig().getStatusChannel());
        this.commandProcessor = new CommandProcessor(uri, Config.getCurrentConfig().getControlChannel(),
                statusReporter);
        initCommandProcessor();
        snapshot = new Snapshot(Instant.EPOCH);
        if (!restoreFromDb()) {
            registerInitialTags();
        }
    }

    /**
     * Restore the state from the database
     *
     * @return Whether we could restore from db, if false, we're starting from a clean slate
     */
    private boolean restoreFromDb() {
        Connection connection = null;
        Snapshot oldSnapshot = this.snapshot;
        try {
            connection = Database.createConnection(EnumSet.of(READ_WRITE));
            Optional<Snapshot> snapshot = Snapshot.loadLatest(connection);
            if (snapshot.isPresent()) {
                this.snapshot = snapshot.get();
                connection.commit();
                return true;
            } else {
                List<Event> events = Event.loadAll(connection);
                Snapshot snapshotBefore = this.snapshot;
                // Instant now = Instant.now(); // TODO: Handle future events later!
                for (Event event : events) {
                    if (!event.isRemoved()/* && event.getTime().isBefore(now)*/) { // TODO: Future events later!
                        this.snapshot = event.apply(this.snapshot);
                        this.snapshot.save(connection);
                    }
                }
                connection.commit();
                return !Objects.equals(this.snapshot, snapshotBefore);
            }
        } catch (SQLException | IOException e) {
            LOG.error("An error occurred when restoring from database!", e);
            this.snapshot = oldSnapshot;
            try {
                if (connection != null) {
                    connection.rollback();
                }
            } catch (SQLException e2) {
                LOG.error("Error in rollback after previous error", e2);
            }
            return false;
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    LOG.error("Error while closing connection", e);
                }
            }
        }
    }

    private void registerInitialTags() {
        Snapshot oldSnapshot = this.snapshot;
        Connection connection = null;
        try {
            connection = Database.createConnection(EnumSet.of(READ_WRITE));
            for (Team team : Config.getCurrentConfig().getTeams()) {
                for (TagId tag : team.getTags()) {
                    AddTagEvent e = new AddTagEvent(Instant.EPOCH, tag, team.getTeamNb());
                    e.save(connection);
                    this.snapshot = e.apply(this.snapshot);
                    this.snapshot.save(connection);
                }
            }
            connection.commit();
        } catch (SQLException e) {
            LOG.error("An error occurred when registering initial tags!", e);
            this.snapshot = oldSnapshot;
            try {
                if (connection != null) {
                    connection.rollback();
                }
            } catch (SQLException e2) {
                LOG.error("Error in rollback after previous error", e2);
            }
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    LOG.error("Error while closing connection", e);
                }
            }
        }
    }

    private void initCommandProcessor() {
        commandProcessor.addHandler(new PingHandler());
        commandProcessor.addHandler(
                new EventCommandHandler<>(AddTagCommand.class, AddTagEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(
                new EventCommandHandler<>(RemoveTagCommand.class, RemoveTagEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(
                new EventCommandHandler<>(CorrectionCommand.class, CorrectionEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(
                new EventCommandHandler<>(SetStartTimeCommand.class, StartEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(
                new EventCommandHandler<>(SetEndTimeCommand.class, EndEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(new EventCommandHandler<>(SetStatusCommand.class,
                StatusChangeEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(new EventCommandHandler<>(SetStatusMessageCommand.class,
                MessageEvent::fromCommand, this::queueEvent));
        commandProcessor.addHandler(new EventCommandHandler<>(SetUpdateFrequencyCommand.class,
                UpdateFrequencyChangeEvent::fromCommand, this::queueEvent));
    }

    public void addOnStartedCallback(Consumer<Processor> onStarted) {
        onStartedCallbacks.add(onStarted);
    }

    private void notifyStarted() {
        onStartedCallbacks.parallelStream().forEach(f -> f.accept(this));
    }

    /**
     * Run the processor. We implement Runnable so that it can
     * be started in a separate thread.
     */
    @Override
    public void run() {
        LOG.info("Spinning up processor!");
        List<ReaderConfig> readers = Config.getCurrentConfig().getReaders();
        for (int i = 0; i < readers.size(); i++) {
            spawnReaderListener(i);
        }
        Thread commandThread = new Thread(commandProcessor);
        threads.add(commandThread);
        commandThread.start();
        notifyStarted();
        try {
            while (!Thread.currentThread().isInterrupted()) {
                Event event = eventQueue.take();
                processEvent(event);
                // TODO(Roel): Do deferred event processing later!
            }
        } catch (InterruptedException ignored) {
        }
        LOG.info("Stopping processor!");
        executorService.shutdownNow();
        commandProcessor.stop();
        readerListeners.stream().forEach(listener -> {
            try {
                listener.unsubscribe();
            } catch (JedisConnectionException ignored) {
            }
        });
        threads.stream().forEach(thread -> {
            thread.interrupt();
            try {
                thread.join();
            } catch (InterruptedException ignored) {
            }
        });
        LOG.info("Bye bye!");
    }

    private Optional<Long> getLastUpdateForReader(Connection connection, int readerId) throws SQLException {
        String statement = "SELECT \"updateCount\" FROM \"tagSeenEvents\" WHERE \"readerId\" = ? "
                + "ORDER BY \"updateCount\" DESC FETCH FIRST ROW ONLY";
        try (PreparedStatement stmt = connection.prepareStatement(statement)) {
            stmt.setInt(1, readerId);
            ResultSet rs = stmt.executeQuery();
            if (rs.next()) {
                return Optional.of(rs.getLong("updateCount"));
            } else {
                return Optional.empty();
            }
        }
    }

    private void trySpawnReaderListener(int readerId) {
        URI uri = Config.getCurrentConfig().getReader(readerId).getURI();
        String updateChannel = JedisHelper.dbLocalChannel(Config.getCurrentConfig().getUpdateChannel(), uri);
        try {
            Optional<Long> lastUpdate;
            {
                Connection connection = null;
                try {
                    connection = Database.createConnection(EnumSet.of(READ_ONLY));
                    lastUpdate = getLastUpdateForReader(connection, readerId);
                    connection.commit();
                } catch (SQLException e) {
                    if (connection != null)
                        connection.rollback();
                    throw e;
                } finally {
                    if (connection != null) {
                        connection.close();
                    }
                }
            }
            Jedis subJedis = JedisHelper.get(uri);
            ReaderListener listener = new ReaderListener(readerId, this::queueEvent, lastUpdate);
            readerListeners.add(listener);
            Thread thread = new Thread(() -> {
                try {
                    LOG.info("Reader listener {} subscribing to channel {} on uri {}", readerId, updateChannel,
                            uri);
                    subJedis.subscribe(listener, updateChannel);
                } catch (JedisConnectionException e) {
                    LOG.error(
                            "Reader listener {} stopped (uri: {}) because of a connection failure, scheduling reconnect",
                            readerId, uri, e);
                    executorService.schedule(() -> trySpawnReaderListener(readerId), 5L, TimeUnit.SECONDS);
                }
            });
            threads.add(thread);
            thread.start();
        } catch (SQLException e) {
            LOG.error("Error fetching last update for reader {} from database, scheduling retry", readerId, e);
            executorService.schedule(() -> trySpawnReaderListener(readerId), 5L, TimeUnit.SECONDS);
        } catch (JedisConnectionException e) {
            LOG.error("Couldn't connect to reader {}, uri {}, scheduling reconnect", readerId, uri, e);
            executorService.schedule(() -> trySpawnReaderListener(readerId), 5L, TimeUnit.SECONDS);
        }
    }

    private void spawnReaderListener(int readerId) {
        executorService.submit(() -> trySpawnReaderListener(readerId));
    }

    private void processEvent(Event event) {
        logProcessEvent(event);
        Connection connection = null;
        Snapshot oldSnapshot = this.snapshot;
        try {
            connection = Database.createConnection(EnumSet.of(READ_WRITE));
            Event firstEvent = event;
            if (event.isUnique()) {
                Optional<Event> other = Event.loadUnique(connection, event.getClass());
                if (other.isPresent()) {
                    other.get().setRemoved(connection, true);
                    if (!other.get().getTime().isAfter(event.getTime())) {
                        firstEvent = other.get();
                    }
                }
            }
            event.save(connection);
            Snapshot snapshotToUpdateFrom = this.snapshot;
            if (!firstEvent.getTime().isAfter(this.snapshot.getSnapshotTime())) {
                LOG.debug("Event before current snapshot, loading snapshot before");
                Optional<Snapshot> s = Snapshot.loadBefore(connection, firstEvent.getTime());
                if (s.isPresent())
                    snapshotToUpdateFrom = s.get();
                else
                    snapshotToUpdateFrom = new Snapshot(Instant.EPOCH);
            }
            List<Event> events;
            Snapshot.deleteAfter(connection, snapshotToUpdateFrom);
            LOG.debug("Updating from snapshot: {}", snapshotToUpdateFrom.getId());
            if (snapshotToUpdateFrom.getId().isPresent()) {
                assert snapshotToUpdateFrom.getEventId().isPresent();
                events = Event.loadAfter(connection, snapshotToUpdateFrom.getSnapshotTime(),
                        snapshotToUpdateFrom.getEventId().get());
            } else {
                events = Event.loadAll(connection);
            }
            for (Event e : events) {
                if (!e.isRemoved()) {
                    snapshotToUpdateFrom = e.apply(snapshotToUpdateFrom);
                    snapshotToUpdateFrom.save(connection);
                }
            }
            LOG.debug("Updated up to snapshot: {}", snapshotToUpdateFrom.getId().get());
            this.snapshot = snapshotToUpdateFrom;
            connection.commit();
            // TODO: Provide a sensible message for NEW_SNAPSHOT?
            statusReporter.broadcast(new StatusMessage(StatusMessage.MessageType.NEW_SNAPSHOT, "New snapshot!"));
            if (eventCallbacks.containsKey(event)) {
                eventCallbacks.get(event).accept(true);
                eventCallbacks.remove(event);
            }
        } catch (SQLException | IOException e) {
            LOG.error("Error when handling event!", e);
            this.snapshot = oldSnapshot;
            try {
                if (connection != null)
                    connection.rollback();
            } catch (SQLException e2) {
                LOG.error("Error in rollback after previous error!", e2);
            }
            // TODO(Roel): Reschedule event!
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (SQLException e) {
                    LOG.error("Error while closing connection", e);
                }
            }
        }
    }

    private void logProcessEvent(Event event) {
        if (LOG.isDebugEnabled()) {
            try {
                String eventStr = Serialization.getJsonMapper().writeValueAsString(event);
                LOG.debug("Processing event: {}", eventStr);
            } catch (JsonProcessingException ignored) {
            }
        }
    }

    /**
     * Queue a tag update for processing.
     */
    private void queueEvent(Event event) {
        try {
            eventQueue.put(event);
        } catch (InterruptedException ignored) {
        }
    }

    private void queueEvent(Event event, Consumer<Boolean> callback) {
        try {
            eventCallbacks.put(event, callback);
            eventQueue.put(event);
        } catch (InterruptedException ignored) {
        }
    }
}