4

I have a Java Spring based web application and I want to insert a record to a table only if the table does not contain any rows that are "similar" (according to some specific, irrelevant criteria) to the new row.

Because this is a multi-threaded environment, I cannot use a SELECT+INSERT two-step combination as it would expose me to a race condition.

The same question was first asked and answered here and here several years ago. Unfortunately, the questions have got only a little attention and the provided answer is not sufficient to my needs.

Here's the code I currently have and it's not working:

@Component("userActionsManager")
@Transactional
public class UserActionsManager implements UserActionsManagerInterface {

    @PersistenceContext(unitName = "itsadDB")
    private EntityManager manager;

    @Resource(name = "databaseManager")
    private DB db;

    ...

    @SuppressWarnings("unchecked")
    @Override
    @PreAuthorize("hasRole('ROLE_USER') && #username == authentication.name")
    public String giveAnswer(String username, String courseCode, String missionName, String taskCode, String answer) {
        ...

        List<Submission> submissions = getAllCorrectSubmissions(newSubmission);
        List<Result>     results     = getAllCorrectResults(result);

        if (submissions.size() > 0
        ||  results.size()     > 0) throw new SessionAuthenticationException("foo");

        manager.persist(newSubmission);
        manager.persist(result);

        submissions = getAllCorrectSubmissions(newSubmission);
        results     = getAllCorrectResults(result);

        for (Submission s : submissions) manager.lock(s, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        for (Result     r : results    ) manager.lock(r, LockModeType.OPTIMISTIC_FORCE_INCREMENT);

        manager.flush();

        ...
    }

    @SuppressWarnings("unchecked")
    private List<Submission> getAllCorrectSubmissions(Submission newSubmission) {
        Query q = manager.createQuery("SELECT s FROM Submission AS s WHERE s.missionTask = ?1 AND s.course = ?2 AND s.user = ?3 AND s.correct = true");
        q.setParameter(1, newSubmission.getMissionTask());
        q.setParameter(2, newSubmission.getCourse());
        q.setParameter(3, newSubmission.getUser());
        return (List<Submission>) q.getResultList();
    }

    @SuppressWarnings("unchecked")
    private List<Result> getAllCorrectResults(Result result) {
        Query q = manager.createQuery("SELECT r FROM Result AS r WHERE r.missionTask = ?1 AND r.course = ?2 AND r.user = ?3");
        q.setParameter(1, result.getMissionTask());
        q.setParameter(2, result.getCourse());
        q.setParameter(3, result.getUser());
        return (List<Result>) q.getResultList();
    }

...

}

According to the answer provided here I am supposed to somehow use OPTIMISTIC_FORCE_INCREMENT but it's not working. I suspect that the provided answer is erroneous so I need a better one.

edit:

Added more context related code. Right now this code still has a race condition. When I make 10 simultaneous HTTP POST requests approximately 5 rows will get erroneously inserted. Other 5 requests are rejected with HTTP error code 409 (conflict). The correct code would guarantee that only 1 row would get inserted to the database no matter how many concurrent requests I make. Making the method synchronous is not a solution since the race condition still manifests for some unknown reason (I tested it).

Community
  • 1
  • 1
Hyena
  • 87
  • 8
  • why don't you try to save the objects in a synchronous function or block. this will solve your duplicate entries problem – Ekansh Rastogi Oct 07 '15 at 12:55
  • @EkanshRastogi only if it would be a single instance, when deployed on multiple servers this approach won't work. – M. Deinum Oct 07 '15 at 13:05
  • What isn't working? Also if you want to be really save use pessimistic locking and `SERIALIZABLE` for a transaction. True it will kill your performance but that way you really leave it to the database to handle it. – M. Deinum Oct 07 '15 at 13:08
  • @EkanshRastogi, the previous programmer actually had made the method synchronous but the race condition happened anyway so I removed it and I am now trying to solve it some other way. I still don't know why the synchronous method fix did not work because it would seem logical that it worked. I managed to trigger the race condition by making 10 POST requests in a for cycle from javascript. – Hyena Oct 07 '15 at 13:16
  • @M.Deinum Currently when I call this method 10 times concurrently about ~5 rows are still inserted in the database each time. Other 5 HTTP POST requests get error 409. – Hyena Oct 07 '15 at 13:16
  • Your code will not work without correct database locking. You are manually updating each row, but this isn't visible earlier as after the commit. Instead of retrieving rows from the database do a count (should be faster) and use `SERIALIZABLE` to get a lock on the database. – M. Deinum Oct 07 '15 at 13:51
  • @M.Deinum I added @Transactional(isolation = Isolation.SERIALIZABLE) to the method and now I'm getting this exception: Warning: StandardWrapperValve[dispatcher]: Servlet.service() for servlet dispatcher threw exception org.springframework.transaction.InvalidIsolationLevelException: JtaTransactionManager does not support custom isolation levels by default - switch 'allowCustomIsolationLevels' to 'true' – Hyena Oct 07 '15 at 14:11
  • @Hyena: I know this is too late, but, in regards to Isolation.SERIALIZABLE throwing an exception, it is possible you need to [create your own JpaDialect](http://www.byteslounge.com/tutorials/spring-change-transaction-isolation-level-example") – jolo Jan 29 '16 at 16:08

1 Answers1

0

Unfortunately after several days of research I was unable to find a short and simple solution to my problem. Since my time budget is not unlimited I had to come up with a workaround. Call it a kludge if you may.

Since the whole HTTP request is a transaction, it will be rolled back at the sight of any conflicts. I am using this for my advantage by locking a special entity within the context of the whole HTTP request. Should multiple HTTP requests be received at the same time, all but one will result in some PersistenceException.

In the beginning of the transaction I am checking whether no other correct answers have been submitted yet. During that check the lock is already effective so no race condition could happen. The lock is effective until the answer is submitted. This basically simulates a critical section as a SELECT+INSERT two step query on the application level (in pure MySQL I would have used the INSERT IF NOT EXISTS construct).

This approach has some drawbacks. Whenever two students submit an answer at the same time, one of them will be thrown an exception. This is sort of bad for performance and bandwidth because the student who received HTTP STATUS 409 has to resubmit their answer.

To compensate the latter, I am automatically retrying to submit the answer on the server side a couple of times between randomly chosen time intervals. See the according HTTP request controller code is below:

@Controller
@RequestMapping("/users")
public class UserActionsController {
    @Autowired
    private SessionRegistry sessionRegistry;

    @Autowired
    @Qualifier("authenticationManager")
    private AuthenticationManager authenticationManager;    
    @Resource(name = "userActionsManager")
    private UserActionsManagerInterface userManager;
    @Resource(name = "databaseManager")
    private DB db;

    .
    .
    .

    @RequestMapping(value = "/{username}/{courseCode}/missions/{missionName}/tasks/{taskCode}/submitAnswer", method = RequestMethod.POST)
    public @ResponseBody
    Map<String, Object> giveAnswer(@PathVariable String username,
            @PathVariable String courseCode, @PathVariable String missionName,
            @PathVariable String taskCode, @RequestParam("answer") String answer, HttpServletRequest request) {
        init(request);
        db.log("Submitting an answer to task `"+taskCode+"` of mission `"+missionName+
               "` in course `"+courseCode+"` as student `"+username+"`.");
        String str = null;
        boolean conflict = true;

        for (int i=0; i<10; i++) {
            Random rand = new Random();
            int ms = rand.nextInt(1000);

            try {
                str = userManager.giveAnswer(username, courseCode, missionName, taskCode, answer);
                conflict = false;
                break;
            }
            catch (EntityExistsException e) {throw new EntityExistsException();}
            catch (PersistenceException e) {}
            catch (UnexpectedRollbackException e) {}

            try {
                Thread.sleep(ms);
            } catch(InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
        if (conflict) str = userManager.giveAnswer(username, courseCode, missionName, taskCode, answer);

        if (str == null) db.log("Answer accepted: `"+answer+"`.");
        else             db.log("Answer rejected: `"+answer+"`.");

        Map<String, Object> hm = new HashMap<String, Object>();

        hm.put("success", str == null);
        hm.put("message", str);

        return hm;
    }
}

If for some reason the controller is unable to commit the transaction 10 times in a row then it will try one more time but will not attempt to catch the possible exceptions. When an exception is thrown on the 11th try then it will be processed by the global exception controller and the client will receive HTTP STATUS 409. The global exception controller is defined below.

@ControllerAdvice
public class GlobalExceptionController {
    @Resource(name = "staticDatabaseManager")
    private StaticDB db;

    @ExceptionHandler(SessionAuthenticationException.class)
    @ResponseStatus(value=HttpStatus.FORBIDDEN, reason="session has expired") //403
    public ModelAndView expiredException(HttpServletRequest request, Exception e) {
        ModelAndView mav = new ModelAndView("exception");
        mav.addObject("name", e.getClass().getSimpleName());
        mav.addObject("message", e.getMessage());
        return mav;
    }

    @ExceptionHandler({UnexpectedRollbackException.class, 
                       EntityExistsException.class,
                       OptimisticLockException.class,
                       PersistenceException.class})
    @ResponseStatus(value=HttpStatus.CONFLICT, reason="conflicting requests") //409
    public ModelAndView conflictException(HttpServletRequest request, Exception e) {
        ModelAndView mav = new ModelAndView("exception");
        mav.addObject("name", e.getClass().getSimpleName());
        mav.addObject("message", e.getMessage());

        synchronized (db) {
            db.setUserInfo(request);
            db.log("Conflicting "+request.getMethod()+" request to "+request.getRequestURI()+" ("+e.getClass().getSimpleName()+").", Log.LVL_SECURITY);
        }        

        return mav;
    }

    //ResponseEntity<String> customHandler(Exception ex) {
    //    return new ResponseEntity<String>("Conflicting requests, try again.", HttpStatus.CONFLICT);
    //}
}

Finally, the giveAnswer method itself utilizes a special entity with a primary key lock_addCorrectAnswer. I lock that special entity with the OPTIMISTIC_FORCE_INCREMENT flag which makes sure that no two transactions can have overlapping execution times for the giveAnswer method. The respective code can be seen below:

@Component("userActionsManager")
@Transactional
public class UserActionsManager implements UserActionsManagerInterface {

    @PersistenceContext(unitName = "itsadDB")
    private EntityManager manager;

    @Resource(name = "databaseManager")
    private DB db;

    .
    .
    .

    @SuppressWarnings("unchecked")
    @Override
    @PreAuthorize("hasRole('ROLE_USER') && #username == authentication.name")
    public String giveAnswer(String username, String courseCode, String missionName, String taskCode, String answer) {
        .
        .
        .
        if (!userCanGiveAnswer(user, course, missionTask)) {
            error = "It is forbidden to submit an answer to this task.";
            db.log(error, Log.LVL_MAJOR);
            return error;
        }
        .
        .
        .
        if (correctAnswer) {
            .
            .
            .          
            addCorrectAnswer(newSubmission, result);
            return null;
        }

        newSubmission = new Submission(user, course, missionTask, answer, false);
        manager.persist(newSubmission);
        return error;
    }

    private void addCorrectAnswer(Submission submission, Result result) {
        String var = "lock_addCorrectAnswer";
        Global global = manager.find(Global.class, var);

        if (global == null) { 
            global = new Global(var, 0);
            manager.persist(global);
            manager.flush();
        }
        manager.lock(global, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        manager.persist(submission);
        manager.persist(result);
        manager.flush();

        long submissions = getCorrectSubmissionCount(submission);
        long results     = getResultCount(result);
        if (submissions > 1 || results > 1) throw new EntityExistsException();
    }

    private long getCorrectSubmissionCount(Submission newSubmission) {
        Query q = manager.createQuery("SELECT count(s) FROM Submission AS s WHERE s.missionTask = ?1 AND s.course = ?2 AND s.user = ?3 AND s.correct = true");
        q.setParameter(1, newSubmission.getMissionTask());
        q.setParameter(2, newSubmission.getCourse());
        q.setParameter(3, newSubmission.getUser());
        return (Long) q.getSingleResult();
    }

    private long getResultCount(Result result) {
        Query q = manager.createQuery("SELECT count(r) FROM Result AS r WHERE r.missionTask = ?1 AND r.course = ?2 AND r.user = ?3");
        q.setParameter(1, result.getMissionTask());
        q.setParameter(2, result.getCourse());
        q.setParameter(3, result.getUser());
        return (Long) q.getSingleResult();
    }
}

It is important to note that the entity Global has to have a version annotation in its class for the OPTIMISTIC_FORCE_INCREMENT to work (see code below).

@Entity
@Table(name = "GLOBALS")
public class Global implements Serializable {
    .
    .
    .
    @Id
    @Column(name = "NAME", length = 32)
    private String key;
    @Column(name = "INTVAL")
    private int intVal;
    @Column(name = "STRVAL", length = 4096)
    private String strVal;
    @Version
    private Long version;
    .
    .
    .
}

Such an approach can be optimized even further. Instead of using the same lock name lock_addCorrectAnswer for all giveAnswer calls, I could generate the lock name deterministically from the name of the submitting user. For example, if the student's username is Hyena then the primary key for the lock entity would be lock_Hyena_addCorrectAnswer. That way multiple students could submit answers at the same time without receiving any conflicts. However, if a malicious user spams the HTTP POST method for submitAnswer 10x in parallel they will be prevented by the this locking mechanism.

Hyena
  • 87
  • 8