// Copyright © 2015 HSL <https://www.hsl.fi>
// This program is dual-licensed under the EUPL v1.2 and AGPLv3 licenses.
package fi.hsl.parkandride.back;
import com.querydsl.sql.postgresql.PostgreSQLQueryFactory;
import fi.hsl.parkandride.core.domain.Lock;
import fi.hsl.parkandride.core.domain.LockAcquireFailedException;
import fi.hsl.parkandride.core.domain.LockException;
import fi.hsl.parkandride.core.service.ValidationService;
import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import javax.inject.Inject;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
// NOTE: class not marked with @Transactional to allow some tests to run
// non-transactionally (and allow the tests to create the transactions within
// separate threads -> thread pool with max 2 connections is enough).
// Those test methods that require transactionality (those that do not run
// operations on LockDao in a separate thread), are annotated with
// @Transactional to have a transaction in test method scope.
public class LockDaoTest extends AbstractDaoTest {
private static final String LOCK_OWNER_NAME = "test-lock-owner";
private static final String TEST_LOCK_NAME = "test-lock";
private static final Duration TEST_LOCK_DURATION = Duration.standardSeconds(10);
@Inject
private PostgreSQLQueryFactory queryFactory;
@Inject
private ValidationService validationService;
@Inject
private PlatformTransactionManager transactionManager;
private LockDao lockDao;
@Rule
public ExpectedException thrown = ExpectedException.none();
@Before
public void createLockDao() {
lockDao = new LockDao(queryFactory, validationService, LOCK_OWNER_NAME);
}
@Before
public void clean_database_so_that_test_do_not_need_to_release_locks() {
cleanup();
}
@Test
@Transactional
public void lock_acquisition_creates_a_lock_in_database_and_releasing_deletes_it() {
Lock lock = lockDao.acquireLock(TEST_LOCK_NAME, TEST_LOCK_DURATION);
assertNotNull(lock);
Optional<Lock> lockReadFromDatabase = lockDao.selectLockIfExists(TEST_LOCK_NAME);
assertTrue(lockReadFromDatabase.isPresent());
assertThat(lock, is(lockReadFromDatabase.get()));
lockDao.releaseLock(lock);
lockReadFromDatabase = lockDao.selectLockIfExists(TEST_LOCK_NAME);
assertFalse(lockReadFromDatabase.isPresent());
}
@Test
public void lock_cannot_be_acquired_by_another_owner_when_it_is_taken_and_still_valid() throws Exception {
testTakenLockAcquisitionWithAnotherOwnerName("another-owner");
}
@Test
public void lock_cannot_be_acquired_even_by_same_owner_when_it_is_taken_and_still_valid() throws Exception {
testTakenLockAcquisitionWithAnotherOwnerName(LOCK_OWNER_NAME);
}
private void testTakenLockAcquisitionWithAnotherOwnerName(String anotherLockOwnerName) throws Exception {
// Acquire the lock (win the race for the lock)
Lock winningLock = runTxInOtherThread(tx -> lockDao.acquireLock(TEST_LOCK_NAME, TEST_LOCK_DURATION)).get();
assertNotNull(winningLock);
assertThat(winningLock.owner, is(LOCK_OWNER_NAME));
// Try to acquire it with another thread (as if another server)
LockDao anotherLockDao = new LockDao(queryFactory, validationService, anotherLockOwnerName);
Exception losingLockException = null;
Lock losingLock = null;
try {
losingLock = runTxInOtherThread(tx -> anotherLockDao.acquireLock(TEST_LOCK_NAME, TEST_LOCK_DURATION)).get();
} catch (Exception e) {
losingLockException = e;
}
// Verify that another thread did not get the lock & threw an exception
assertNull(losingLock);
assertThat(losingLockException, instanceOf(ExecutionException.class));
assertThat(losingLockException.getCause(), instanceOf(LockAcquireFailedException.class));
}
@Test
public void expired_lock_can_be_claimed_and_releasing_expired_lock_does_not_delete_valid_lock() throws Exception {
// Acquire the lock that expires immediately
Lock expiringLock = runTxInOtherThread(tx -> lockDao.acquireLock(TEST_LOCK_NAME, Duration.ZERO)).get();
assertNotNull(expiringLock);
assertThat(expiringLock.owner, is(LOCK_OWNER_NAME));
// New Lock can be claimed when existing lock has expired
Lock newLock = runTxInOtherThread(tx -> lockDao.acquireLock(TEST_LOCK_NAME, TEST_LOCK_DURATION)).get();
assertNotNull(newLock);
assertThat(newLock.owner, is(LOCK_OWNER_NAME));
// Trying to release the expired lock does not release the valid newLock
Boolean wasLockReleased = runTxInOtherThread(tx -> lockDao.releaseLock(expiringLock)).get();
assertFalse(wasLockReleased);
// Verify that database still contains the valid lock
Optional<Lock> lockReadFromDatabase = runTxInOtherThread(tx -> lockDao.selectLockIfExists(TEST_LOCK_NAME)).get();
assertTrue(lockReadFromDatabase.isPresent());
assertThat(lockReadFromDatabase.get(), equalTo(newLock));
}
@Test
public void cannot_release_lock_that_is_not_owned() {
thrown.expect(LockException.class);
thrown.expectMessage("Lock is not owned");
Lock someoneElsesLock = new Lock(TEST_LOCK_NAME, "someone-else", DateTime.now().plus(TEST_LOCK_DURATION));
lockDao.releaseLock(someoneElsesLock);
}
@Test
public void lock_acquisition_race_loss_causes_LockAcquireFailedException() throws Exception {
// Run a thread to acquire a lock: notice that lock is not taken, but wait before inserting the lock to database
LosingLockDao losingLockDao = new LosingLockDao(queryFactory, validationService, "another-owner");
Future<Lock> losingLockFuture = runTxInOtherThread(tx -> losingLockDao.acquireLock(TEST_LOCK_NAME, TEST_LOCK_DURATION));
losingLockDao.waitUntilReadyToInsert();
// Acquire the lock with another thread (win the race for the lock)
Lock winningLock = runTxInOtherThread(tx -> lockDao.acquireLock(TEST_LOCK_NAME, TEST_LOCK_DURATION)).get();
assertNotNull(winningLock);
assertThat(winningLock.owner, is(LOCK_OWNER_NAME));
// Let the first thread to proceed with an attempt to acquire lock
losingLockDao.proceedWithInsertLock();
// Collect the result of the first thread
Exception losingLockException = null;
Lock losingLock = null;
try {
losingLock = losingLockFuture.get();
} catch (Exception e) {
losingLockException = e;
}
// Verify that LosingLockDao did not get the lock & threw an exception
assertNull(losingLock);
assertThat(losingLockException, instanceOf(ExecutionException.class));
assertThat(losingLockException.getCause(), instanceOf(LockAcquireFailedException.class));
}
private <T> Future<T> runTxInOtherThread(TransactionCallback<T> transactionCallback) {
return Executors.newSingleThreadExecutor()
.submit(() -> {
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
txTemplate.setTimeout(1);
return txTemplate.execute(transactionCallback);
});
}
public static class LosingLockDao extends LockDao {
private final Semaphore externalWaitForReadySemaphore = new Semaphore(0);
private final Semaphore waitForInsertSemaphore = new Semaphore(0);
public LosingLockDao(PostgreSQLQueryFactory queryFactory, ValidationService validationService,
String lockOwnerName) {
super(queryFactory, validationService, lockOwnerName);
}
public void waitUntilReadyToInsert() {
externalWaitForReadySemaphore.acquireUninterruptibly();
}
public void proceedWithInsertLock() {
waitForInsertSemaphore.release();
}
@Override
public Lock insertLock(String lockName, Duration lockDuration) {
externalWaitForReadySemaphore.release();
waitForInsertSemaphore.acquireUninterruptibly();
return super.insertLock(lockName, lockDuration);
}
}
}