Java tutorial
/* * 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.reader; import com.fasterxml.jackson.core.JsonProcessingException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.llrp.ltk.generated.messages.RO_ACCESS_REPORT; import org.llrp.ltk.generated.parameters.TagReportData; import org.llrp.ltk.types.LLRPMessage; import org.ulyssis.ipp.config.Config; import org.ulyssis.ipp.config.ReaderConfig; import org.ulyssis.ipp.control.CommandProcessor; import org.ulyssis.ipp.control.handlers.PingHandler; import org.ulyssis.ipp.status.StatusMessage; import org.ulyssis.ipp.status.StatusReporter; import org.ulyssis.ipp.updates.TagUpdate; 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.Response; import redis.clients.jedis.Transaction; import redis.clients.jedis.exceptions.JedisConnectionException; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.ByteChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.concurrent.*; // TODO: Go over all of the error handling, and evaluate if it is appropriate // TODO: Set some sort of scheduled task for timeouts, try to reinitialize? public final class Reader implements Runnable { private static final Logger LOG = LogManager.getLogger(Reader.class); private final CommandProcessor commandProcessor; private final ReaderOptions options; private final ReaderConfig readerConfig; private final StatusReporter statusReporter; private final LLRPReader llrpReader; private final ScheduledExecutorService executorService; private Optional<ByteChannel> replayChannel = Optional.empty(); private final String updateChannel; // NOTE: be careful, Jedis instances are not threadsafe! private final Jedis jedis; private long updateCount = 0L; private Instant lastUpdate = Instant.now(); private final Map<TagId, Instant> lastUpdateForTag; private boolean speedwayInitialized = false; /** * Create a new reader and connect to Redis. * * options are passed in, rather than * accessed through a singleton or such, to improve testability * and modularity, and to prevent hidden dependencies and * eventual threading issues. * * @param options * The command line options to use for this reader. */ public Reader(ReaderOptions options) { this.options = options; this.readerConfig = Config.getCurrentConfig().getReader(options.getId()); this.llrpReader = new LLRPReader(this::messageReceived, this::errorOccurred); if (readerConfig.getType() == ReaderConfig.Type.SIMULATOR) { executorService = Executors.newSingleThreadScheduledExecutor(); } else { executorService = null; } if (options.getNoRedis()) { LOG.info("Not using Redis, setting initial update count to 0."); this.updateCount = 0L; this.jedis = null; } else { this.jedis = JedisHelper.get(readerConfig.getURI()); try { this.updateCount = jedis.llen("updates"); } catch (JedisConnectionException e) { LOG.error("Couldn't connect to Jedis when getting update count. Setting 0 instead.", e); this.updateCount = 0L; // TODO: Is 0 appropriate? } } String statusChannel = Config.getCurrentConfig().getStatusChannel(); this.statusReporter = new StatusReporter(readerConfig.getURI(), statusChannel); String controlChannel = Config.getCurrentConfig().getControlChannel(); this.commandProcessor = new CommandProcessor(readerConfig.getURI(), controlChannel, statusReporter); commandProcessor.addHandler(new PingHandler()); this.updateChannel = JedisHelper.dbLocalChannel(Config.getCurrentConfig().getUpdateChannel(), readerConfig.getURI()); options.getReplayFile().ifPresent(replayFile -> { try { LOG.info("Opening replay file: {}", replayFile); ByteChannel channel = Files.newByteChannel(replayFile, StandardOpenOption.APPEND, StandardOpenOption.CREATE); this.replayChannel = Optional.of(channel); } catch (IOException e) { LOG.error("Couldn't open channel for logging to replay file: {}", replayFile, e); } }); this.lastUpdateForTag = new HashMap<>(); } /** * Run the reader. Reader implements runnable, so that we can * do this in its own thread. */ @Override public void run() { LOG.info("Spinning up reader!"); ReaderConfig.Type type = Config.getCurrentConfig().getReader(options.getId()).getType(); if (type == ReaderConfig.Type.LLRP) { initSpeedway(); if (!speedwayInitialized) { shutdownHook(); return; } } else if (type == ReaderConfig.Type.SIMULATOR) { initSimulator(); } Thread commandThread = new Thread(commandProcessor); commandThread.start(); statusReporter.broadcast(new StatusMessage(StatusMessage.MessageType.STARTED_UP, String.format("Started up reader %s!", options.getId()))); try { while (!Thread.currentThread().isInterrupted()) { Duration maxUpdateInterval = Duration.ofMillis(Config.getCurrentConfig().getMaxUpdateInterval()); if (maxUpdateInterval.minus(Duration.between(lastUpdate, Instant.now())).isNegative()) { lastUpdate = Instant.now(); LOG.warn("No update received in {} seconds!", maxUpdateInterval.getSeconds()); statusReporter.broadcast(new StatusMessage(StatusMessage.MessageType.NO_UPDATES, String.format("No update received in %s seconds!", maxUpdateInterval.getSeconds()))); } Thread.sleep(1000L); } } catch (InterruptedException e) { // We don't care about this exception } commandProcessor.stop(); commandThread.interrupt(); try { commandThread.join(); } catch (InterruptedException ignored) { } shutdownHook(); } private void initSpeedway() { ExecutorService executor = Executors.newSingleThreadExecutor(); try { while (!speedwayInitialized && !Thread.currentThread().isInterrupted()) { // We're doing this in another thread because LLRPReader doesn't // interrupt properly. Callable<Boolean> runCallable = () -> llrpReader .run(Config.getCurrentConfig().getSpeedwayURI(options.getId())); Future<Boolean> initFuture = executor.submit(runCallable); try { speedwayInitialized = initFuture.get(); } catch (ExecutionException e) { LOG.error("Starting the Speedway caused an exception.", e); speedwayInitialized = false; } if (!speedwayInitialized) { LOG.error("Couldn't start the Speedway! Retrying in {} ms.", Config.getCurrentConfig().getRetryInterval()); statusReporter.broadcast(new StatusMessage(StatusMessage.MessageType.STARTUP_FAILURE, "Failed to start the Speedway!")); Thread.sleep(Config.getCurrentConfig().getRetryInterval()); } } } catch (InterruptedException e) { // Shutting down } } private void initSimulator() { for (ReaderConfig.SimulatedTeam team : readerConfig.getSimulatedTeams()) { Runnable runnable = () -> simulateOneTeam(team); double startingPosition = Config.getCurrentConfig().getTrackLength() / 2.0; double distanceToGo; if (readerConfig.getPosition() > startingPosition) { distanceToGo = readerConfig.getPosition() - startingPosition; } else { distanceToGo = Config.getCurrentConfig().getTrackLength() - startingPosition + readerConfig.getPosition(); } double avgVelocity = Config.getCurrentConfig().getTrackLength() / team.getLapTime(); double time = distanceToGo / avgVelocity; executorService.schedule(runnable, (long) time, TimeUnit.SECONDS); } } private void simulateOneTeam(ReaderConfig.SimulatedTeam team) { Instant instant = Instant.now(); TagId tag = team.getTag(); if (acceptUpdate(instant, tag)) { pushUpdate(instant, tag); } Runnable runnable = () -> simulateOneTeam(team); executorService.schedule(runnable, team.getLapTime(), TimeUnit.SECONDS); } /** * Perform cleanup on shutdown. (When the thread is interrupted.) */ private void shutdownHook() { statusReporter.broadcast(new StatusMessage(StatusMessage.MessageType.SHUTDOWN, String.format("Shutting down reader %s.", options.getId()))); if (speedwayInitialized) { LOG.info("Shutting down reader!"); boolean successfulStop = llrpReader.stop(); if (!successfulStop) { LOG.error("Could not stop the Speedway!"); } else { LOG.info("Successfully stopped the reader!"); } } replayChannel.ifPresent(channel -> { final Path replayFile = options.getReplayFile().get(); try { channel.close(); } catch (IOException e) { LOG.error("Error while closing replay log file: {}.", replayFile, e); } try { if (Files.size(replayFile) == 0L) { LOG.info("Deleting empty replay file: {}", replayFile); Files.delete(replayFile); } } catch (IOException e) { LOG.error("Couldn't check size of replay log {}, or delete it.", replayFile, e); } }); LOG.info("Bye bye!"); } // TODO: Recover in case of lost connection with Speedway. public void messageReceived(LLRPMessage msg) { if (msg.getTypeNum() == RO_ACCESS_REPORT.TYPENUM) { RO_ACCESS_REPORT report = (RO_ACCESS_REPORT) msg; List<TagReportData> tags = report.getTagReportDataList(); for (TagReportData tagReportData : tags) { // NOTE: We're using Instant.now() instead of relying on the timestamp // of the update. The delay should be small, and we'll keep the // counting systems in sync using NTP. Instant now = Instant.now(); TagId tag = LLRPReader.decodeEPCParameter(tagReportData.getEPCParameter()); if (acceptUpdate(now, tag)) { pushUpdate(now, tag); } } } } public void errorOccurred(String s) { LOG.error("An error occurred: {}", s); statusReporter.broadcast(new StatusMessage(StatusMessage.MessageType.MISC_ERROR, s)); } /** * Check whether this tag code has been seen at least config.getMinUpdateInterval() * ago. If not, the update should be ignored. */ private boolean acceptUpdate(Instant now, TagId tag) { lastUpdate = now; boolean result = !lastUpdateForTag.containsKey(tag) || Duration.ofMillis(Config.getCurrentConfig().getMinUpdateInterval()) .minus(Duration.between(lastUpdateForTag.get(tag), now)).isNegative(); if (result) { lastUpdateForTag.put(tag, now); } return result; } /** * Push an update to Redis at the given instant with the given tag. * * TODO: Refactor: split off a "pusher", so we can use it in the integration tests. */ private void pushUpdate(Instant now, TagId tag) { TagUpdate update = new TagUpdate(options.getId(), updateCount, now, tag); try { byte[] updateBytes = Serialization.getJsonMapper().writeValueAsBytes(update); logUpdate(updateBytes); if (options.getNoRedis()) { updateCount++; } else { LOG.info("Pushing update {}:{} to Redis, tag: {}", update.getReaderId(), update.getUpdateCount(), tag); try { Transaction t = jedis.multi(); Response<Long> nextUpdateCount = t.rpush("updates".getBytes(), updateBytes); t.publish(updateChannel, String.valueOf(updateCount)); t.exec(); updateCount = nextUpdateCount.get(); } catch (JedisConnectionException e) { LOG.error("Error pushing update {} to Redis.", update.getUpdateCount(), e); } } } catch (JsonProcessingException e) { LOG.error("Error formatting update as JSON", e); } } /** * Log an update to the replay log, if enabled. */ private void logUpdate(byte[] updateBytes) { LOG.debug("Update: {}", LOG.isDebugEnabled() ? new String(updateBytes) : null); replayChannel.ifPresent(channel -> { try { channel.write(ByteBuffer.wrap(updateBytes)); byte[] newLine = "\n".getBytes(); channel.write(ByteBuffer.wrap(newLine)); } catch (IOException e) { LOG.error("Couldn't log to replay file: {}", options.getReplayFile().get(), e); } }); } }