package tc.oc.api.model; import java.io.IOException; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.function.Predicate; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; import javax.inject.Inject; import com.google.common.cache.LoadingCache; import com.google.common.collect.ImmutableSet; import com.google.common.reflect.TypeToken; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import tc.oc.api.connectable.Connectable; import tc.oc.api.docs.virtual.DeletableModel; import tc.oc.api.docs.virtual.Model; import tc.oc.api.document.DocumentGenerator; import tc.oc.api.message.MessageListener; import tc.oc.api.message.MessageQueue; import tc.oc.api.message.types.FindMultiResponse; import tc.oc.api.message.types.FindRequest; import tc.oc.api.message.types.ModelDelete; import tc.oc.api.message.types.ModelUpdate; import tc.oc.commons.core.concurrent.FutureUtils; import tc.oc.commons.core.logging.Loggers; import tc.oc.commons.core.util.CacheUtils; import tc.oc.commons.core.util.ProxyUtils; import tc.oc.minecraft.suspend.Suspendable; public abstract class ModelStore<T extends Model> implements MessageListener, Connectable, Suspendable { protected Logger logger; protected @Inject QueryService<T> queryService; protected @Inject MessageQueue primaryQueue; protected @Inject @ModelSync ExecutorService modelSync; protected @Inject ModelDispatcher dispatcher; protected ModelMeta<T, ? super T> meta; private LoadingCache<String, T> proxies; private final Map<String, T> byId = new HashMap<>(); @Inject void init(Loggers loggers, ModelRegistry registry) { this.logger = loggers.get(getClass()); this.meta = (ModelMeta<T, ? super T>) registry.meta(new TypeToken<T>(getClass()){}); this.proxies = CacheUtils.newCache(id -> ProxyUtils.newProviderProxy(meta.completeTypeRaw(), () -> byId(id))); } @Override public void connect() throws IOException { primaryQueue.bind(ModelUpdate.class); primaryQueue.bind(ModelDelete.class); primaryQueue.subscribe(this, modelSync); refreshAllSync(); } @Override public void disconnect() throws IOException { primaryQueue.unsubscribe(this); } @Override public void resume() { refreshAll(); } /** * Return a transparent proxy for the stored document with the given ID. * Every method call on the proxy is forwarded to the version of the document * stored at the time of the call. * * The document does not need to actually exist when the proxy is created, * but it must exist any time a method is called. * * TODO: Avoid double-wrapping. In most cases, this will return a proxy to * another proxy. The inner proxy is from {@link DocumentGenerator} to implement * the document interface, and the outer proxy is the one created here to look * it up in the tracker. Would be nice if these were combined into a single proxy. */ public T proxy(String id) { return proxies.getUnchecked(id); } /** * Return the stored document with the given ID * * @throws IllegalStateException if there is no stored document with the given ID */ public T byId(String id) { final T doc = byId.get(id); if(doc == null) { throw new IllegalStateException("Missing " + meta.name() + " " + id); } return doc; } public Optional<T> tryId(String id) { return Optional.ofNullable(byId.get(id)); } public int count() { return byId.size(); } public Stream<T> all() { return byId.values().stream(); } public Set<T> set() { return ImmutableSet.copyOf(byId.values()); } public Set<T> subset(Predicate<? super T> filter) { return byId.values().stream().filter(filter).collect(Collectors.toSet()); } public @Nullable T first(Comparator<? super T> order, @Nullable Predicate<? super T> filter) { T min = null; for(T doc : byId.values()) { if((filter == null || filter.test(doc)) && (min == null || order.compare(doc, min) < 0)) { min = doc; } } return min; } protected void logAction(String verb, T model) { if(logger.isLoggable(Level.FINE)) { logger.fine(verb + ' ' + meta.name() + ' ' + model._id()); } } protected FindRequest<T> refreshAllRequest() { return new FindRequest<>(meta.completeType()); } protected ListenableFuture<FindMultiResponse<T>> sendRefreshAll() { logger.fine("Requesting refresh of all documents"); return queryService.find(refreshAllRequest()); } public ListenableFuture<FindMultiResponse<T>> refreshAll() { return FutureUtils.mapSync( sendRefreshAll(), response -> handleRefreshAll(response, Runnable::run), modelSync ); } /** * Must be called on the ModelSync thread */ protected FindMultiResponse<T> refreshAllSync() { // At startup, defer notifications to the next tick, after all ModelStores are populated, // because some handlers will try to lookup foreign keys across stores. return handleRefreshAll(Futures.getUnchecked(sendRefreshAll()), modelSync); } protected FindMultiResponse<T> handleRefreshAll(FindMultiResponse<T> response, Executor notificationExecutor) { logger.fine(() -> "Storing " + response.documents().size() + " documents"); response.documents().forEach(after -> handleUpdate(after._id(), after, notificationExecutor)); final Set<T> gone = new HashSet<>(byId.values()); gone.removeAll(response.documents()); gone.forEach(doc -> handleUpdate(doc._id(), null, notificationExecutor)); return response; } @HandleMessage public void onUpdate(ModelUpdate<T> message) { handleUpdate(message.document()._id(), message.document()); } @HandleMessage public void onDelete(ModelDelete<T> message) { handleUpdate(message.document_id(), null); } private boolean exists(@Nullable T doc) { return doc != null && !(doc instanceof DeletableModel && ((DeletableModel) doc).dead()); } private void handleUpdate(String id, @Nullable T after) { handleUpdate(id, after, Runnable::run); } private void handleUpdate(String id, @Nullable T after, Executor notificationExecutor) { final T before = byId.get(id); final T latest; if(exists(before)) { if(exists(after)) { logAction("Update", after); unindex(before); reindex(after); latest = after; } else { logAction("Delete", before); unindex(before); remove(before); latest = before; } } else if(exists(after)) { logAction("Create", after); reindex(after); latest = after; } else { latest = null; } if(exists(latest)) { notificationExecutor.execute(() -> dispatcher.modelUpdated(before, after, latest)); } } /** * Called only when a document is deleted, after {@link #unindex}. * * Subclasses only need to override this method if they need to do some extra * cleanup, besides unindexing. */ protected void remove(T doc) { proxies.invalidate(doc._id()); } /** * Called when a document is updated, or deleted. The argument is the state * of the document before the change. * * Subclasses can override this in order to maintain their own indexes. */ protected void unindex(T doc) { byId.remove(doc._id()); } /** * This method is called when a document is created or updated. The argument * is the state of the document after the change. * * Subclasses can override this in order to maintain their own indexes. */ protected void reindex(T doc) { byId.put(doc._id(), doc); } }