diff --git a/README.md b/README.md index c81b995..ba4cf19 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,44 @@ For XDC locks requiring strong consistency, opt for a multi-site Aerospike clust #### Notes A lock exists only within the scope of a Client represented by `CLIENT_ID`. + +--- + +## Configuring Lock Timing + +By default, every `LockBase` uses the library's built-in timing constants: + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `lockTtl` | 90 s | How long the lock is held before the storage layer expires it automatically | +| `waitForLock` | 90 s | Maximum time a blocking `acquireLock` call waits for a contended lock | +| `sleepBetweenRetries` | 1000 ms | Sleep interval between successive acquisition attempts | + +To customise these values supply a `LockConfiguration` to the `LockBase` builder using standard `Duration` values. Any value left unset falls back to the library default. + +```java +// Tight SLO service: short TTL, short wait, fast retry +LockConfiguration config = LockConfiguration.builder() + .lockTtl(Duration.ofSeconds(30)) // hold locks for 30 s + .waitForLock(Duration.ofSeconds(10)) // wait at most 10 s for a contended lock + .sleepBetweenRetries(Duration.ofMillis(500)) // retry every 500 ms + .build(); + +DistributedLockManager lockManager = DistributedLockManager.builder() + .clientId("CLIENT_ID") + .farmId("FA1") + .lockBase(LockBase.builder() + .mode(LockMode.EXCLUSIVE) + .lockConfiguration(config) // <-- inject custom config + .lockStore(AerospikeStore.builder() + .aerospikeClient(aerospikeClient) + .namespace("NAMESPACE") + .setSuffix("distributed_lock") + .build()) + .build()) + .build(); +lockManager.initialize(); +``` + +> **Backward compatibility**: omitting `lockConfiguration(...)` from the builder is fully supported +> and produces identical behaviour to all previous library versions. diff --git a/src/main/java/com/phonepe/dlm/DistributedLockManager.java b/src/main/java/com/phonepe/dlm/DistributedLockManager.java index 8d3bd93..a092395 100644 --- a/src/main/java/com/phonepe/dlm/DistributedLockManager.java +++ b/src/main/java/com/phonepe/dlm/DistributedLockManager.java @@ -19,6 +19,7 @@ import com.phonepe.dlm.exception.ErrorCode; import com.phonepe.dlm.lock.Lock; import com.phonepe.dlm.lock.base.LockBase; +import com.phonepe.dlm.lock.base.LockConfiguration; import com.phonepe.dlm.lock.level.LockLevel; import java.time.Duration; @@ -42,7 +43,8 @@ public void initialize() { * This method attempts to acquire the lock immediately and throws exception if lock is unavailable * It does not wait if the lock is currently held by another thread. *

- * The lock will be acquired for default time period {@link LockBase#DEFAULT_LOCK_TTL_SECONDS} + * The lock will be held for the TTL configured in {@link LockBase}'s {@link LockConfiguration}, + * defaulting to {@link LockConfiguration#DEFAULT_LOCK_TTL} seconds. * * @param lock The lock to be acquired. * @throws DLMException with {@link ErrorCode#LOCK_UNAVAILABLE} if lock is already acquired @@ -68,8 +70,9 @@ public void tryAcquireLock(final Lock lock, final Duration duration) { * it will wait until the lock becomes available. * It blocks the thread until the lock is acquired. *

- * By default, timeout is {@link LockBase#DEFAULT_WAIT_FOR_LOCK_IN_SECONDS} - * The lock will be acquired for default time period {@link LockBase#DEFAULT_LOCK_TTL_SECONDS} + * By default, timeout is {@link LockConfiguration#DEFAULT_WAIT_FOR_LOCK} seconds and + * TTL is {@link LockConfiguration#DEFAULT_LOCK_TTL} seconds. Override both via + * {@link LockConfiguration} on the {@link LockBase}. * * @param lock The lock to be acquired. * @throws DLMException with {@link ErrorCode#LOCK_UNAVAILABLE} if lock is not available even after the timeout @@ -83,7 +86,8 @@ public void acquireLock(final Lock lock) { * it will wait until the lock becomes available. * It blocks the thread until the lock is acquired. *

- * By default, timeout is {@link LockBase#DEFAULT_WAIT_FOR_LOCK_IN_SECONDS} + * By default, timeout is {@link LockConfiguration#DEFAULT_WAIT_FOR_LOCK} seconds. Override + * via {@link LockConfiguration} on the {@link LockBase}. * * @param lock The lock to be acquired. * @param duration The lock duration in seconds for which lock will be held diff --git a/src/main/java/com/phonepe/dlm/lock/ILockable.java b/src/main/java/com/phonepe/dlm/lock/ILockable.java index edf1c6d..0a0f514 100644 --- a/src/main/java/com/phonepe/dlm/lock/ILockable.java +++ b/src/main/java/com/phonepe/dlm/lock/ILockable.java @@ -17,7 +17,8 @@ package com.phonepe.dlm.lock; import com.phonepe.dlm.exception.ErrorCode; -import com.phonepe.dlm.lock.base.LockBase; + +import com.phonepe.dlm.lock.base.LockConfiguration; import java.time.Duration; @@ -29,7 +30,9 @@ public interface ILockable { * This method attempts to acquire the lock immediately and throws exception if lock is unavailable * It does not wait if the lock is currently held by another thread. *

- * The lock will be acquired for default time period {@link LockBase#DEFAULT_LOCK_TTL_SECONDS} + * The lock will be acquired for the TTL configured via + * {@link LockConfiguration} (field: {@code lockTtl}), defaulting to + * {@link LockConfiguration#DEFAULT_LOCK_TTL} seconds. * * @param lock The lock to be acquired. * @throws DLMException with {@link ErrorCode#LOCK_UNAVAILABLE} if lock is already acquired @@ -51,8 +54,9 @@ public interface ILockable { * it will wait until the lock becomes available. * It blocks the thread until the lock is acquired. *

- * By default, timeout is {@link LockBase#DEFAULT_WAIT_FOR_LOCK_IN_SECONDS} - * The lock will be acquired for default time period {@link LockBase#DEFAULT_LOCK_TTL_SECONDS} + * By default, timeout is {@link com.phonepe.dlm.lock.base.LockConfiguration#DEFAULT_WAIT_FOR_LOCK} seconds, + * and TTL is {@link com.phonepe.dlm.lock.base.LockConfiguration#DEFAULT_LOCK_TTL} seconds. + * Override both via {@link com.phonepe.dlm.lock.base.LockConfiguration}. * * @param lock The lock to be acquired. * @throws DLMException with {@link ErrorCode#LOCK_UNAVAILABLE} if lock is not available even after the timeout @@ -64,7 +68,8 @@ public interface ILockable { * it will wait until the lock becomes available. * It blocks the thread until the lock is acquired. *

- * By default, timeout is {@link LockBase#DEFAULT_WAIT_FOR_LOCK_IN_SECONDS} + * By default, timeout is {@link com.phonepe.dlm.lock.base.LockConfiguration#DEFAULT_WAIT_FOR_LOCK} seconds. + * Override via {@link com.phonepe.dlm.lock.base.LockConfiguration}. * * @param lock The lock to be acquired. * @param duration The lock duration in seconds for which lock will be held diff --git a/src/main/java/com/phonepe/dlm/lock/base/LockBase.java b/src/main/java/com/phonepe/dlm/lock/base/LockBase.java index 43bcd70..8804bd5 100644 --- a/src/main/java/com/phonepe/dlm/lock/base/LockBase.java +++ b/src/main/java/com/phonepe/dlm/lock/base/LockBase.java @@ -31,21 +31,69 @@ import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; +/** + * Core implementation of the distributed locking contract defined by {@link ILockable}. + * + *

{@code LockBase} orchestrates lock acquisition and release by delegating storage operations + * to the configured {@link ILockStore}. Timing behaviour — TTL, wait timeout, and sleep between retries — + * is governed by the {@link LockConfiguration} supplied at construction time. When no configuration + * is provided the library falls back to {@link LockConfiguration}'s built-in defaults + * ({@link LockConfiguration#DEFAULT_LOCK_TTL}, + * {@link LockConfiguration#DEFAULT_WAIT_FOR_LOCK}, + * {@link LockConfiguration#DEFAULT_SLEEP_BETWEEN_RETRIES}), preserving full backward + * compatibility. + * + *

Typical construction

+ *
{@code
+ * // Backward-compatible: omitting lockConfiguration uses library defaults.
+ * LockBase lockBase = LockBase.builder()
+ *         .mode(LockMode.EXCLUSIVE)
+ *         .lockStore(aerospikeStore)
+ *         .build();
+ *
+ * // Custom timing for a service with tighter SLOs.
+ * LockBase lockBase = LockBase.builder()
+ *         .mode(LockMode.EXCLUSIVE)
+ *         .lockStore(aerospikeStore)
+ *         .lockConfiguration(LockConfiguration.builder()
+ *         .lockTtl(Duration.ofSeconds(30))
+ *         .waitForLock(Duration.ofSeconds(10))
+ *         .sleepBetweenRetries(Duration.ofMillis(500))
+ *         .build())
+ *         .build();
+ * }
+ * + *

This class is thread-safe provided the supplied {@link ILockStore} is also thread-safe. + */ @Slf4j -@AllArgsConstructor -@Builder @Getter +@Builder +@AllArgsConstructor public class LockBase implements ILockable { - public static final Duration DEFAULT_LOCK_TTL_SECONDS = Duration.ofSeconds(90); - public static final Duration DEFAULT_WAIT_FOR_LOCK_IN_SECONDS = Duration.ofSeconds(90); - public static final int WAIT_TIME_FOR_NEXT_RETRY = 1000; // 1 second + /** + * The storage backend used to persist and remove lock records. + */ private final ILockStore lockStore; - private final LockMode mode; // Not implemented now, but can be leveraged in the future. + + /** + * The locking mode (e.g. {@link LockMode#EXCLUSIVE}). + * Not actively enforced today but reserved for future multi-mode support. + */ + private final LockMode mode; + + /** + * Timing configuration for this lock base instance. + *

+ * When not set via the builder, defaults to {@link LockConfiguration#builder() build()}, + * which applies the library-standard defaults (90 s TTL, 90 s wait, 1 000 ms retry). + */ + @Builder.Default + private final LockConfiguration lockConfiguration = LockConfiguration.builder().build(); @Override public void tryAcquireLock(final Lock lock) { - tryAcquireLock(lock, DEFAULT_LOCK_TTL_SECONDS); + tryAcquireLock(lock, lockConfiguration.getLockTtl()); } @Override @@ -55,12 +103,12 @@ public void tryAcquireLock(final Lock lock, final Duration duration) { @Override public void acquireLock(final Lock lock) { - acquireLock(lock, DEFAULT_LOCK_TTL_SECONDS, DEFAULT_WAIT_FOR_LOCK_IN_SECONDS); + acquireLock(lock, lockConfiguration.getLockTtl(), lockConfiguration.getWaitForLock()); } @Override public void acquireLock(final Lock lock, final Duration duration) { - acquireLock(lock, duration, DEFAULT_WAIT_FOR_LOCK_IN_SECONDS); + acquireLock(lock, duration, lockConfiguration.getWaitForLock()); } @Override @@ -77,7 +125,7 @@ public void acquireLock(final Lock lock, final Duration duration, final Duration throw e; } if (e.getErrorCode() == ErrorCode.LOCK_UNAVAILABLE) { - sleep(); + sleep(lockConfiguration.getSleepBetweenRetries()); continue; } throw e; @@ -85,7 +133,6 @@ public void acquireLock(final Lock lock, final Duration duration, final Duration } while (!success.get()); } - @Override public boolean releaseLock(final Lock lock) { if (lock.getAcquiredStatus().get()) { @@ -101,9 +148,16 @@ private void writeToStore(final Lock lock, final Duration ttlSeconds) { lock.getAcquiredStatus().compareAndSet(false, true); } - private static void sleep() { + /** + * Sleeps for the configured sleepBetweenRetries before the next acquisition attempt. + * + * @param sleepBetweenRetries the duration to sleep + * @throws DLMException wrapping {@link InterruptedException} if the thread is interrupted + * while sleeping, with the interrupt status restored on the current thread + */ + private static void sleep(final Duration sleepBetweenRetries) { try { - Thread.sleep(WAIT_TIME_FOR_NEXT_RETRY); + Thread.sleep(sleepBetweenRetries.toMillis()); } catch (InterruptedException e) { log.error("Error sleeping the thread", e); Thread.currentThread().interrupt(); diff --git a/src/main/java/com/phonepe/dlm/lock/base/LockConfiguration.java b/src/main/java/com/phonepe/dlm/lock/base/LockConfiguration.java new file mode 100644 index 0000000..88bf756 --- /dev/null +++ b/src/main/java/com/phonepe/dlm/lock/base/LockConfiguration.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2024 Original Author(s), PhonePe India Pvt. Ltd. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.phonepe.dlm.lock.base; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Duration; + +/** + * Immutable configuration encapsulating all timing parameters that govern lock-acquisition + * behaviour in the Distributed Lock Manager. + * + *

All parameters are expressed as {@link Duration} values. Any parameter left unset in the + * builder falls back to its corresponding library default via {@code @Builder.Default}, preserving + * full backward compatibility for callers that do not supply a {@code LockConfiguration}. + * + *

Default values

+ * + * + *

Usage

+ *
{@code
+ * // Use library defaults — identical behaviour to omitting lockConfiguration from the builder.
+ * LockConfiguration defaults = LockConfiguration.builder().build();
+ *
+ * // Custom configuration for a service with tighter SLOs.
+ * LockConfiguration custom = LockConfiguration.builder()
+ *         .lockTtl(Duration.ofSeconds(30))
+ *         .waitForLock(Duration.ofSeconds(10))
+ *         .sleepBetweenRetries(Duration.ofMillis(500))
+ *         .build();
+ *
+ * DistributedLockManager lockManager = DistributedLockManager.builder()
+ *         .clientId("MY_SERVICE")
+ *         .farmId("MHX")
+ *         .lockBase(LockBase.builder()
+ *                 .mode(LockMode.EXCLUSIVE)
+ *                 .lockConfiguration(custom)
+ *                 .lockStore(aerospikeStore)
+ *                 .build())
+ *         .build();
+ * }
+ * + *

Instances of this class are immutable and therefore safe for concurrent use without + * external synchronisation. + */ +@Getter +@Builder +public final class LockConfiguration { + + /** + * Default lock time-to-live: 90 seconds. + *

+ * The lock is held for this duration before the storage layer expires it automatically, + * protecting against deadlocks caused by holders that crash or fail to release the lock. + */ + public static final Duration DEFAULT_LOCK_TTL = Duration.ofSeconds(90); + + /** + * Default maximum wait time for lock acquisition: 90 seconds. + *

+ * When a caller invokes a blocking {@code acquireLock} variant without specifying a timeout, + * the library retries for at most this duration before throwing a + * {@link com.phonepe.dlm.exception.DLMException} with + * {@link com.phonepe.dlm.exception.ErrorCode#LOCK_UNAVAILABLE}. + */ + public static final Duration DEFAULT_WAIT_FOR_LOCK = Duration.ofSeconds(90); + + /** + * Default polling interval between successive lock-acquisition retries: 1 second. + *

+ * When a lock is unavailable, the library sleeps for this duration before the next attempt. + * Tuning this value trades off CPU/network overhead against acquisition latency under + * contention. + */ + public static final Duration DEFAULT_SLEEP_BETWEEN_RETRIES = Duration.ofMillis(1_000); + + /** + * The duration for which a successfully acquired lock is held before automatic expiry. + */ + @Builder.Default + private final Duration lockTtl = DEFAULT_LOCK_TTL; + + /** + * The maximum duration a blocking {@code acquireLock} call will wait for a contended lock. + */ + @Builder.Default + private final Duration waitForLock = DEFAULT_WAIT_FOR_LOCK; + + /** + * The sleep duration between successive acquisition attempts when a lock is unavailable. + */ + @Builder.Default + private final Duration sleepBetweenRetries = DEFAULT_SLEEP_BETWEEN_RETRIES; +} diff --git a/src/test/java/com/phonepe/dlm/lock/base/LockConfigurationTest.java b/src/test/java/com/phonepe/dlm/lock/base/LockConfigurationTest.java new file mode 100644 index 0000000..d36ead6 --- /dev/null +++ b/src/test/java/com/phonepe/dlm/lock/base/LockConfigurationTest.java @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2024 Original Author(s), PhonePe India Pvt. Ltd. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.phonepe.dlm.lock.base; + +import com.phonepe.dlm.lock.mode.LockMode; +import com.phonepe.dlm.lock.storage.ILockStore; +import org.junit.Test; +import org.mockito.Mockito; + +import java.time.Duration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Unit tests for {@link LockConfiguration}. + * + *

Covers: + *

+ */ +public class LockConfigurationTest { + + @Test + public void partialConfigFallsBackToDefaultsForUnsetFields() { + // Only lockTtl is set; the other two should fall back to library defaults. + final LockConfiguration config = LockConfiguration.builder() + .lockTtl(Duration.ofSeconds(45)) + .build(); + + assertEquals(Duration.ofSeconds(45), config.getLockTtl()); + assertEquals(LockConfiguration.DEFAULT_WAIT_FOR_LOCK, config.getWaitForLock()); + assertEquals(LockConfiguration.DEFAULT_SLEEP_BETWEEN_RETRIES, config.getSleepBetweenRetries()); + } + + @Test + public void lockBaseBuilderBackwardCompatibleWithoutConfiguration() { + final ILockStore mockStore = Mockito.mock(ILockStore.class); + + final LockBase lockBase = LockBase.builder() + .mode(LockMode.EXCLUSIVE) + .lockStore(mockStore) + .build(); + + assertNotNull(lockBase.getLockConfiguration()); + assertEquals(LockConfiguration.DEFAULT_LOCK_TTL, lockBase.getLockConfiguration().getLockTtl()); + assertEquals(LockConfiguration.DEFAULT_WAIT_FOR_LOCK, lockBase.getLockConfiguration().getWaitForLock()); + assertEquals(LockConfiguration.DEFAULT_SLEEP_BETWEEN_RETRIES, lockBase.getLockConfiguration().getSleepBetweenRetries()); + } + + @Test + public void lockBaseHonoursCustomConfiguration() { + final ILockStore mockStore = Mockito.mock(ILockStore.class); + final LockConfiguration custom = LockConfiguration.builder() + .lockTtl(Duration.ofSeconds(20)) + .waitForLock(Duration.ofSeconds(5)) + .sleepBetweenRetries(Duration.ofMillis(250)) + .build(); + + final LockBase lockBase = LockBase.builder() + .mode(LockMode.EXCLUSIVE) + .lockStore(mockStore) + .lockConfiguration(custom) + .build(); + + assertEquals(Duration.ofSeconds(20), lockBase.getLockConfiguration().getLockTtl()); + assertEquals(Duration.ofSeconds(5), lockBase.getLockConfiguration().getWaitForLock()); + assertEquals(Duration.ofMillis(250), lockBase.getLockConfiguration().getSleepBetweenRetries()); + } + +}