package edu.harvard.econcs.turkserver.server; import java.net.InetAddress; import java.util.Date; import java.util.Map; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.apache.commons.configuration.Configuration; import org.cometd.bayeux.server.BayeuxServer; import org.cometd.bayeux.server.BayeuxServer.SessionListener; import org.cometd.bayeux.server.ServerSession; import org.cometd.server.JettyJSONContextServer; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.ajax.JSON.Convertor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Supplier; import com.google.common.collect.ImmutableMap; import com.google.common.collect.MapMaker; import com.google.common.collect.Table; import com.google.common.collect.Tables; import com.google.inject.Inject; import edu.harvard.econcs.turkserver.Codec; import edu.harvard.econcs.turkserver.Messages; import edu.harvard.econcs.turkserver.QuizMaterials; import edu.harvard.econcs.turkserver.SessionCompletedException; import edu.harvard.econcs.turkserver.SessionOverlapException; import edu.harvard.econcs.turkserver.SimultaneousSessionsException; import edu.harvard.econcs.turkserver.TooManyFailsException; import edu.harvard.econcs.turkserver.TooManySessionsException; import edu.harvard.econcs.turkserver.api.HITWorkerGroup; import edu.harvard.econcs.turkserver.config.TSConfig; import edu.harvard.econcs.turkserver.mturk.HITController; import edu.harvard.econcs.turkserver.schema.Quiz; import edu.harvard.econcs.turkserver.schema.Session; import edu.harvard.econcs.turkserver.server.SessionRecord.SessionStatus; import edu.harvard.econcs.turkserver.server.mysql.ExperimentDataTracker; import edu.harvard.econcs.turkserver.server.mysql.ExperimentDataTracker.SessionSummary; public abstract class SessionServer extends Thread { public static final String ATTRIBUTE = "edu.harvard.econcs.turkserver.sessions"; protected final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName()); final ExperimentDataTracker tracker; final HITController hitCont; final WorkerAuthenticator workerAuth; final Experiments experiments; protected final boolean debugMode; protected final int hitGoal; protected JettyCometD jettyCometD; protected BayeuxServer bayeux; protected ConcurrentMap<ServerSession, HITWorkerImpl> clientToHITWorker; protected Table<String, String, HITWorkerImpl> hitWorkerTable; protected volatile int completedHITs = 0; protected volatile int submittedHITs = 0; private volatile boolean running = true; @Inject public SessionServer( ExperimentDataTracker tracker, HITController hitCont, WorkerAuthenticator workerAuth, Experiments experiments, Configuration config ) throws ClassNotFoundException { this.tracker = tracker; this.hitCont = hitCont; this.workerAuth = workerAuth; this.experiments = experiments; /* * Process configuration */ this.debugMode = config.getBoolean(TSConfig.SERVER_DEBUGMODE); this.hitGoal = config.getInt(TSConfig.SERVER_HITGOAL); /* * create session and string maps */ final MapMaker mm = new MapMaker(); this.clientToHITWorker = mm.makeMap(); Map<String, Map<String, HITWorkerImpl>> rowMap = mm.makeMap(); mm.concurrencyLevel(1); this.hitWorkerTable = Tables.newCustomTable( rowMap, new Supplier<Map<String, HITWorkerImpl>>() { @Override public Map<String, HITWorkerImpl> get() { return mm.makeMap(); } }); } public void injectWebServer(JettyCometD server) { this.jettyCometD = server; } public class UserSessionListener implements SessionListener { @Override public void sessionAdded(ServerSession conn) { HITWorkerImpl previous = clientToHITWorker.get(conn); if( previous != null ) { // This session was previously connected. Associate it with the HITWorker. previous.setServerSession(conn); logger.info(conn.getId() + " reconnected, used to have HIT " + previous.getHitId()); } else { logger.info(conn.toString()); // clientToHITWorker.putIfAbsent(session, null); } } @Override public void sessionRemoved(ServerSession conn, boolean timedout) { String clientId = conn.getId(); if( timedout ) { logger.info("Session " + clientId + " timed out"); sessionDisconnect(conn); } else { logger.info("Session " + clientId + " disconnected"); sessionDisconnect(conn); } } } public void registerConvertor(Class<?> cl, Convertor conv) { // TODO put this elsewhere JettyJSONContextServer jsonContext = (JettyJSONContextServer) bayeux.getOption("jsonContext"); jsonContext.getJSON().addConvertor(cl, conv); } public abstract int getExpsInProgress(); public abstract int getExpsCompleted(); public int getNumCompleted() { return completedHITs; } void sessionView(ServerSession conn, String hitId) { if( hitId != null ) { // This session currently hasn't accepted this HIT HITWorkerImpl prev = clientToHITWorker.remove(conn); logger.info("New user is viewing HIT {}, previous user was {}", hitId, prev); conn.setAttribute("hitId", hitId); // Try to add hitIds that we have no record of (clean reuse) if( !tracker.hitExistsInDB(hitId) ) tracker.saveHITId(hitId); } else { System.out.println("Client " + conn.getId() + " sent null hitId"); } } HITWorkerImpl sessionAccept(ServerSession conn, String hitId, String assignmentId, String workerId) { if( hitId == null ) { logger.warn("Received null hitId for session {}", conn.getId()); return null; } Session hitIdRecord = tracker.getStoredSessionInfo(hitId); try { workerAuth.checkHITValid(hitId, workerId, hitIdRecord); // Create hitId record that would have been saved above if session was null if( hitIdRecord == null ) { hitIdRecord = new Session(); hitIdRecord.setHitId(hitId); } } catch (SessionCompletedException e) { SessionUtils.sendStatus(conn, Codec.status_expfinished, Messages.SESSION_COMPLETED); logger.info("Worker {} connected to experiment after completion (HIT {})", workerId, hitId); // Re-match HITWorker to this person HITWorkerImpl hitw; if( (hitw = hitWorkerTable.get(hitId, workerId)) != null ) { // Match session to HITWorker and vice versa ServerSession oldSession = hitw.cometdSession.get(); if( oldSession == null || !conn.equals(oldSession) ) { hitw.setServerSession(conn); } clientToHITWorker.put(conn, hitw); } return null; } catch (SessionOverlapException e) { SessionUtils.sendStatus(conn, Codec.status_sessionoverlap, Messages.SESSION_OVERLAP); logger.info("Worker {} connected to overlapping session (HIT {})", workerId, hitId); /* * Disable this HIT and prevent recurring problems * We can do this synchronously in this thread */ logger.info("Disabling overlapping HIT {} as it was picked up by someone else", hitId); hitCont.disableHIT(hitId); return null; } // Check for this AFTER possible completed sessions if( !running || completedHITs >= hitGoal ) { SessionUtils.sendStatus(conn, Codec.status_batchfinished, Messages.BATCH_COMPLETED); logger.info("Ignoring connection after quota reached (HIT {})", hitId); return null; } try { workerAuth.checkWorkerLimits(hitId, workerId, hitIdRecord); } catch (SimultaneousSessionsException e) { SessionUtils.sendStatus(conn, Codec.status_simultaneoussessions, Messages.SIMULTANEOUS_SESSIONS); logger.info("Worker {} has accepted too many HITs (HIT {})", workerId, hitId); return null; } catch (TooManySessionsException e) { SessionUtils.sendStatus(conn, Codec.status_toomanysessions, Messages.TOO_MANY_SESSIONS); logger.info("Worker {} has completed too many HITs (HIT {})", workerId, hitId); return null; } // Save some extra information that we can access later, for comparison conn.setAttribute("hitId", hitId); conn.setAttribute("assignmentId", assignmentId); conn.setAttribute("workerId", workerId); try { if( workerAuth.workerRequiresQuiz(workerId) ) { logger.info("Worker {} needs to take quiz (HIT {})", workerId, hitId); QuizMaterials qm = workerAuth.getQuiz(); // TODO: null quiz is passed for static client-side Map<String, Object> data = qm == null ? ImmutableMap.of("status", (Object) Codec.status_quizneeded) : ImmutableMap.of("status", Codec.status_quizneeded, "quiz", qm.toData() ); SessionUtils.sendServiceMsg(conn, data); return null; } logger.info("No quiz required for {}", workerId); } catch (TooManyFailsException e) { SessionUtils.sendStatus(conn, Codec.status_failsauce, Messages.TOO_MANY_FAILS); logger.info("Worker {} has failed quiz too many times (HIT {})", workerId, hitId); return null; } HITWorkerImpl hitw = null; /* * Not reconnection from the same person * Connection was from someone else */ if( hitIdRecord.getWorkerId() != null && !workerId.equals(hitIdRecord.getWorkerId()) ) { logger.info(String.format("HIT %s being replaced by worker %s with assignment %s", hitId, workerId, assignmentId)); } else { logger.info(String.format("HIT %s newly assigned to worker %s with assignment %s", hitId, workerId, assignmentId)); } // Find this HITWorker in table if( (hitw = hitWorkerTable.get(hitId, workerId)) == null ) { // create instance of HITWorker hitw = new HITWorkerImpl(conn, hitIdRecord); hitWorkerTable.put(hitId, workerId, hitw); } // Match session to HITWorker and vice versa ServerSession oldSession = hitw.cometdSession.get(); if( oldSession == null || !conn.equals(oldSession) ) { hitw.setServerSession(conn); } clientToHITWorker.put(conn, hitw); // TODO Alice wants to move this to before the quiz tracker.saveWorkerAssignment(hitw, assignmentId, workerId); // Successful registration, save info tracker.saveIP(hitw, bayeux == null ? // TODO mock this part for unit tests InetAddress.getLoopbackAddress() : bayeux.getContext().getRemoteAddress().getAddress(), new Date()); // subclasses continue with additional logic if necessary return hitw; } void sessionSubmit(ServerSession conn, String survey) { HITWorkerImpl worker = clientToHITWorker.get(conn); if( worker == null ) { logger.warn("Unrecognized client {} tried to submit", conn.getId()); return; } // Write any exit comments / logs for session Session existing = worker.getSessionRecord(); boolean alreadySubmitted = existing.getComment() != null; if( !alreadySubmitted ) { // We do this because the first survey is probably going to be better than subsequent ones tracker.saveExitSurveyResults(worker, survey); updateCompletion(); } // TODO check the total number of possible different tasks as well, from assigner int additional = workerAuth.getSetLimit() - tracker.getSetSessionInfoForWorker(worker.getWorkerId()).size(); SessionUtils.sendStatus(conn, Codec.status_completed, "Thanks for your work! You may do " + additional + " more HITs in this session."); } /** * Reconnect a user to an experiment, counting their disconnection time * @param conn * @param hitw */ void sessionReconnect(ServerSession conn, HITWorkerImpl hitw) { // experiment should send state to user with this callback experiments.workerReconnected(hitw); } void sessionDisconnect(ServerSession conn) { HITWorkerImpl worker = clientToHITWorker.get(conn); if( worker != null ) { experiments.workerDisconnected(worker); } String workerHitId = worker == null ? null : worker.getHitId(); if( workerHitId != null ) { /* * TODO user can accept a hit then close window, but this is the same thing as * accepting multiple hits, holding and refreshing * Fix with a notification receptor later. */ if( !(experiments.workerIsInProgress(worker) || SessionRecord.status(worker.record) == SessionStatus.EXPERIMENT || SessionRecord.status(worker.record) == SessionStatus.COMPLETED) ) { /* If disconnected from lobby or earlier, clear session from worker Id list * also clear the username that was stored from worker * * BUT if in experiment, they need to wait */ tracker.clearWorkerForSession(workerHitId); } } // Debug check for any strange inconsistencies String sessionHitId = (String) conn.getAttribute("hitId"); if( workerHitId != null && sessionHitId != null && !workerHitId.equals(sessionHitId) ) { logger.error("Session and worker HIT IDs don't match for {}", conn.getId()); } } void rcvQuizResults(ServerSession conn, Quiz qr) { String workerId = (String) conn.getAttribute("workerId"); String hitId = (String) conn.getAttribute("hitId"); String assignmentId = (String) conn.getAttribute("assignmentId"); if( workerId == null ) { logger.error("Can't save quiz: unknown worker for {}", conn.getId()); } tracker.saveQuizResults(hitId, workerId, qr); // check if quiz failed if( workerAuth.quizPasses(qr) ) { logger.info("{} passed quiz", workerId); // Run the accept HIT checks again sessionAccept(conn, hitId, assignmentId, workerId); } else { if( workerAuth.tooManyFails(workerId) ) { logger.info("{} failed quiz too many times", workerId); SessionUtils.sendStatus(conn, Codec.status_failsauce); } else { logger.info("{} failed quiz", workerId); SessionUtils.sendStatus(conn, Codec.status_quizfail); } } } void rcvInactiveTime(ServerSession conn, long inactiveStart, long inactiveTime) { HITWorkerImpl worker = clientToHITWorker.get(conn); if( worker == null ) { logger.error("Can't save inactivity: unknown worker for {}", conn.getId()); return; } worker.addInactiveTime(inactiveStart, inactiveTime); } void rcvExperimentServiceMsg(ServerSession conn, Map<String, Object> dataAsMap) { HITWorkerImpl worker = clientToHITWorker.get(conn); if( worker != null ) experiments.rcvServiceMsg(worker, dataAsMap); else logger.warn("Message from unrecognized client: {}", conn.getId()); } boolean rcvExperimentBroadcastMsg(ServerSession conn, Map<String, Object> dataAsMap) { HITWorkerImpl worker = clientToHITWorker.get(conn); if( worker != null ) return experiments.rcvBroadcastMsg(worker, dataAsMap); else logger.warn("Message from unrecognized client: {}", conn.getId()); return false; } /** * A group of workers has completed their tasks. * @param group * @return whether the server should shut down */ boolean groupCompleted(HITWorkerGroup group) { updateCompletion(); // Will interrupt if necesasry return completedHITs >= hitGoal; } void updateCompletion() { SessionSummary currentState = tracker.getSetSessionSummary(); completedHITs = currentState.completedHITs; submittedHITs = currentState.submittedHITs; logger.info(currentState.completedHITs + " HITs completed"); logger.info(currentState.submittedHITs + " HITs submitted"); if( completedHITs >= hitGoal && completedHITs == submittedHITs ) this.interrupt(); } @Override public final void run() { Thread.currentThread().setName(this.getClass().getSimpleName()); Server server = null; try { server = jettyCometD.start(this); } catch (Exception e) { e.printStackTrace(); return; } bayeux = jettyCometD.getBayeux(); experiments.setReferences(bayeux); Thread hcThread = null; if( hitCont != null ) { (hcThread = new Thread(hitCont)).start(); } runServerInit(); // TODO: clean up half-baked experiments in database updateCompletion(); // Hang out until goal # of HITs are reached and shutdown jetty server while( running && completedHITs < hitGoal ) { try { Thread.sleep(5000); } catch (InterruptedException e) {} } logger.info("Goal reached or shut down initiated - deleting remaining HITs"); if( hitCont != null ) { hitCont.disableAndShutdown(); try { hcThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } /* * Sleep until submitted HITs to equal completed HITs * This may be less than hitGoal */ if( !debugMode ) { logger.info("Waiting for experiments to finish and workers to submit HITs"); do try { Thread.sleep(5000); } catch(InterruptedException e ) {} while( getExpsInProgress() > 0 || completedHITs > submittedHITs ); logger.info("Experiments finished and got all HIT submissions"); } else { try { Thread.sleep(1000); } catch (Exception e ) { e.printStackTrace(); } } // Stop experiments thread ScheduledExecutorService exec = experiments.stop(); System.out.println("Waiting for experiment scheduler to stop..."); do try { exec.awaitTermination(1000, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { e.printStackTrace(); } while( !exec.isTerminated() ); System.out.println("Shutting down jetty server"); try { server.stop(); server.join(); } catch (InterruptedException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } } public void shutdown() { running = false; // TODO add stuff from above to in here, like auto expiring hits // Runtime.getRuntime().addShutdownHook(new Thread() { // public void run() { // logger.info("Shutdown initiated"); // } // }); this.interrupt(); } protected abstract void runServerInit(); }