/*
* Copyright 2014 GoDataDriven B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.divolte.server.ip2geo;
import java.io.IOException;
import java.net.InetAddress;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import javax.annotation.ParametersAreNonnullByDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.maxmind.geoip2.model.CityResponse;
@ParametersAreNonnullByDefault
public class ExternalDatabaseLookupService implements LookupService {
private static final Logger logger = LoggerFactory.getLogger(ExternalDatabaseLookupService.class);
private final ExecutorService backgroundWatcher = Executors.newSingleThreadExecutor();
private final WatchService watcher;
private final Path location;
// The reference may be null if we don't have a delegate service yet.
private final AtomicReference<DatabaseLookupService> databaseLookupService;
public ExternalDatabaseLookupService(final Path location) throws IOException {
final Path absoluteLocation = location.toAbsolutePath();
final Path locationParent = absoluteLocation.getParent();
if (null == locationParent) {
throw new IllegalArgumentException("Could not determine parent directory of GeoIP2 database: " + absoluteLocation);
}
this.location = absoluteLocation;
// Do this first, so that if it fails we don't need to clean up resources.
databaseLookupService = new AtomicReference<>(new DatabaseLookupService(absoluteLocation));
// Set things up so that we can reload the database if it changes.
watcher = FileSystems.getDefault().newWatchService();
logger.debug("Monitoring {} for changes affecting {}.", locationParent, location);
locationParent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY);
// The database will be loaded in the background.
backgroundWatcher.execute(this::processWatchEvents);
}
private void processWatchEvents() {
final Thread thisThread = Thread.currentThread();
try {
logger.debug("Starting to wait for watch events");
while (!thisThread.isInterrupted()) {
// Block for the key to be signaled.
logger.debug("Awaiting next file notification...");
final WatchKey key = watcher.take();
try {
final Path parentDirectory = (Path) key.watchable();
for (final WatchEvent<?> event : key.pollEvents()) {
processWatchEvent(parentDirectory, event);
}
} finally {
// Ensure that no matter what happens we will receive subsequent notifications.
key.reset();
}
}
logger.debug("Stopped processing watch events due to interruption.");
} catch (final InterruptedException e) {
logger.debug("Interrupted while waiting for a watch event; stopping.");
// Preserve the interrupt flag.
thisThread.interrupt();
} catch (final ClosedWatchServiceException e) {
logger.debug("Stopped processing watch events; watcher has been closed.");
}
}
private void processWatchEvent(final Path parentDirectory, final WatchEvent<?> event) {
final WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) {
// Ignore these for now; we could treat this as a potential change.
logger.warn("File notification overflow; may have missed database update.");
} else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
final Path modifiedRelativePath = (Path) event.context();
final Path modifiedAbsolutePath = parentDirectory.resolve(modifiedRelativePath);
if (location.equals(modifiedAbsolutePath)) {
logger.debug("Database has been updated; attempting reload: {}", modifiedAbsolutePath);
reloadDatabase();
} else {
logger.debug("Ignoring file modified event: {}", modifiedAbsolutePath);
}
} else {
logger.debug("Ignoring file notification: {}", event);
}
}
private void reloadDatabase() {
try {
final DatabaseLookupService newService = new DatabaseLookupService(location);
final DatabaseLookupService oldService = databaseLookupService.getAndSet(newService);
logger.info("Reloaded database: {}", location);
try {
oldService.close();
} catch (final IOException e) {
logger.warn("Could not close previous database: " + location, e);
}
} catch (final IOException e) {
logger.warn("Database modified but could not reload: " + location, e);
}
}
@Override
public Optional<CityResponse> lookup(final InetAddress address) throws ClosedServiceException {
final DatabaseLookupService service = databaseLookupService.get();
if (null == service) {
throw new ClosedServiceException(this);
}
try {
return service.lookup(address);
} catch (final ClosedServiceException e) {
// We accidentally performed a lookup using an underlying service that was closed
// between us fetching it and using it. Retry via recursion.
return lookup(address);
}
}
@Override
public void close() throws IOException {
backgroundWatcher.shutdown();
watcher.close();
final DatabaseLookupService service = databaseLookupService.getAndSet(null);
if (null != service) {
service.close();
}
}
}