public class InternalPropertiesDao implements Dao {
+ /**
+ * A common prefix used by locks. {@see InternalPropertiesDao#tryLock}
+ */
+ public static final String LOCK_PREFIX = "lock.";
+
+ static final int KEY_MAX_LENGTH = 20;
private static final int TEXT_VALUE_MAX_LENGTH = 4000;
private static final Optional<String> OPTIONAL_OF_EMPTY_STRING = Optional.of("");
return rows.iterator().next();
}
+ /**
+ * Try to acquire a lock with the specified name, for specified duration.
+ *
+ * Returns false if the lock exists with a timestamp > now - duration,
+ * or if the atomic replacement of the timestamp fails (another process replaced first).
+ *
+ * Returns true if the lock does not exist, or if exists with a timestamp <= now - duration,
+ * and the atomic replacement of the timestamp succeeds.
+ *
+ * The lock is considered released when the specified duration has elapsed.
+ */
+ public boolean tryLock(DbSession dbSession, String name, int maxAgeInSeconds) {
+ String key = LOCK_PREFIX + '.' + name;
+ if (key.length() > KEY_MAX_LENGTH) {
+ throw new IllegalArgumentException("lock name is too long");
+ }
+
+ long now = system2.now();
+
+ Optional<String> timestampAsStringOpt = selectByKey(dbSession, key);
+ if (!timestampAsStringOpt.isPresent()) {
+ return tryCreateLock(dbSession, key, String.valueOf(now));
+ }
+
+ String oldTimestampString = timestampAsStringOpt.get();
+ long oldTimestamp = Long.parseLong(oldTimestampString);
+ if (oldTimestamp > now - maxAgeInSeconds * 1000) {
+ return false;
+ }
+
+ return getMapper(dbSession).replaceValue(key, oldTimestampString, String.valueOf(now)) == 1;
+ }
+
+ private boolean tryCreateLock(DbSession dbSession, String name, String value) {
+ try {
+ getMapper(dbSession).insertAsText(name, value, system2.now());
+ return true;
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
private static void checkKey(@Nullable String key) {
checkArgument(key != null && !key.isEmpty(), "key can't be null nor empty");
}
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
+import static org.sonar.db.property.InternalPropertiesDao.KEY_MAX_LENGTH;
+import static org.sonar.db.property.InternalPropertiesDao.LOCK_PREFIX;
public class InternalPropertiesDaoTest {
verifyNoMoreInteractions(mapperMock);
}
+ @Test
+ public void tryLock_succeeds_if_lock_did_not_exist() {
+ long now = new Random().nextInt();
+ when(system2.now()).thenReturn(now);
+ assertThat(underTest.tryLock(dbSession, A_KEY, 60)).isTrue();
+
+ assertThat(underTest.selectByKey(dbSession, key(A_KEY))).contains(String.valueOf(now));
+ }
+
+ @Test
+ public void tryLock_succeeds_if_lock_acquired_before_lease_duration() {
+ int lockDurationSeconds = 60;
+
+ long before = new Random().nextInt();
+ when(system2.now()).thenReturn(before);
+ assertThat(underTest.tryLock(dbSession, A_KEY, lockDurationSeconds)).isTrue();
+
+ long now = before + lockDurationSeconds * 1000;
+ when(system2.now()).thenReturn(now);
+ assertThat(underTest.tryLock(dbSession, A_KEY, lockDurationSeconds)).isTrue();
+
+ assertThat(underTest.selectByKey(dbSession, key(A_KEY))).contains(String.valueOf(now));
+ }
+
+ @Test
+ public void tryLock_fails_if_lock_acquired_within_lease_duration() {
+ long now = new Random().nextInt();
+ when(system2.now()).thenReturn(now);
+ assertThat(underTest.tryLock(dbSession, A_KEY, 60)).isTrue();
+ assertThat(underTest.tryLock(dbSession, A_KEY, 60)).isFalse();
+
+ assertThat(underTest.selectByKey(dbSession, key(A_KEY))).contains(String.valueOf(now));
+ }
+
+ @Test
+ public void tryLock_fails_if_it_would_insert_concurrently() {
+ String name = randomAlphabetic(5);
+ String key = key(name);
+
+ long now = new Random().nextInt();
+ when(system2.now()).thenReturn(now);
+ assertThat(underTest.tryLock(dbSession, name, 60)).isTrue();
+
+ InternalPropertiesMapper mapperMock = mock(InternalPropertiesMapper.class);
+ DbSession dbSessionMock = mock(DbSession.class);
+ when(dbSessionMock.getMapper(InternalPropertiesMapper.class)).thenReturn(mapperMock);
+ when(mapperMock.selectAsText(ImmutableList.of(key)))
+ .thenReturn(ImmutableList.of());
+ doThrow(RuntimeException.class).when(mapperMock).insertAsText(eq(key), anyString(), anyLong());
+
+ assertThat(underTest.tryLock(dbSessionMock, name, 60)).isFalse();
+
+ assertThat(underTest.selectByKey(dbSession, key)).contains(String.valueOf(now));
+ }
+
+ @Test
+ public void tryLock_fails_if_concurrent_caller_succeeded_first() {
+ int lockDurationSeconds = 60;
+ String name = randomAlphabetic(5);
+ String key = key(name);
+
+ long now = 123456;//new Random().nextInt();
+ long oldTimestamp = now - lockDurationSeconds * 1000;
+ when(system2.now()).thenReturn(oldTimestamp);
+ assertThat(underTest.tryLock(dbSession, name, lockDurationSeconds)).isTrue();
+ when(system2.now()).thenReturn(now);
+
+ InternalPropertiesMapper mapperMock = mock(InternalPropertiesMapper.class);
+ DbSession dbSessionMock = mock(DbSession.class);
+ when(dbSessionMock.getMapper(InternalPropertiesMapper.class)).thenReturn(mapperMock);
+ InternalPropertyDto dto = new InternalPropertyDto();
+ dto.setKey(key);
+ dto.setValue(String.valueOf(oldTimestamp - 1));
+ when(mapperMock.selectAsText(ImmutableList.of(key)))
+ .thenReturn(ImmutableList.of(dto));
+
+ assertThat(underTest.tryLock(dbSessionMock, name, lockDurationSeconds)).isFalse();
+
+ assertThat(underTest.selectByKey(dbSession, key)).contains(String.valueOf(oldTimestamp));
+ }
+
+ @Test
+ public void tryLock_throws_if_lock_name_would_produce_too_long_key() {
+ String tooLongName = randomAlphabetic(KEY_MAX_LENGTH - LOCK_PREFIX.length());
+
+ expectedException.expect(IllegalArgumentException.class);
+ expectedException.expectMessage("lock name is too long");
+ underTest.tryLock(dbSession, tooLongName, 60);
+ }
+
+ private String key(String name) {
+ return LOCK_PREFIX + '.' + name;
+ }
+
private void expectKeyNullOrEmptyIAE() {
expectedException.expect(IllegalArgumentException.class);
expectedException.expectMessage("key can't be null nor empty");