• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Hyshmily / hotkey / 27856464831

20 Jun 2026 01:44AM UTC coverage: 93.742% (-0.9%) from 94.613%
27856464831

push

github

Hyshmily
feat: add distributed lock (tryLock/tryLockAndRun) with Redis-based AutoReleaseLock

Signed-off-by: Hyshmily <cxm8607@outlook.com>

1108 of 1235 branches covered (89.72%)

Branch coverage included in aggregate %.

69 of 97 new or added lines in 5 files covered. (71.13%)

2 existing lines in 2 files now uncovered.

3191 of 3351 relevant lines covered (95.23%)

4.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.48
/common/src/main/java/io/github/hyshmily/hotkey/sync/RedisLockProvider.java
1
/*
2
 * Copyright 2026 Hyshmily. All Rights Reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package io.github.hyshmily.hotkey.sync;
17

18
import static io.github.hyshmily.hotkey.cache.CacheKeysPolicy.invalidCacheKey;
19

20
import io.github.hyshmily.hotkey.cache.distributedlock.AutoReleaseLock;
21
import io.github.hyshmily.hotkey.cache.distributedlock.LockProvider;
22
import io.github.hyshmily.hotkey.constants.HotKeyConstants;
23
import java.time.Duration;
24
import java.util.List;
25
import java.util.Objects;
26
import java.util.concurrent.ThreadLocalRandom;
27
import java.util.concurrent.TimeUnit;
28
import lombok.RequiredArgsConstructor;
29
import lombok.extern.slf4j.Slf4j;
30
import org.springframework.data.redis.core.StringRedisTemplate;
31
import org.springframework.data.redis.core.script.DefaultRedisScript;
32

33
/**
34
 * Redis-backed {@link LockProvider} using {@code SET NX PX} with UUID-based
35
 * safe release and configurable retry.
36
 *
37
 * <p>Validation and fallback are concentrated in the implementation:
38
 * <ul>
39
 *   <li>{@link #tryLock(String, long, TimeUnit)} — uses configured defaults</li>
40
 *   <li>{@link #tryLock(String, long, TimeUnit, int, int, int)} — validates
41
 *       each count; negative values fall back to defaults with a warning</li>
42
 * </ul>
43
 *
44
 * <p>Algorithm (JetCache-inspired):
45
 * <ol>
46
 *   <li>{@code SET key uuid NX PX ttl} — atomically acquire with expiry</li>
47
 *   <li>On transient failure ({@code null}) — {@code GET} to inquire;
48
 *       if the value is our UUID the lock is considered acquired
49
 *       (optimistic recovery)</li>
50
 *   <li>{@code close()} — Lua {@code GET + DEL} with UUID comparison
51
 *       for safe release, with retry on transient Redis errors</li>
52
 * </ol>
53
 */
54
@Slf4j
3✔
55
@RequiredArgsConstructor
56
public class RedisLockProvider implements LockProvider {
57

58
  private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
7✔
59
    "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
60
    Long.class
61
  );
62

63
  private static String lockId() {
64
    return (
1✔
65
      Long.toHexString(ThreadLocalRandom.current().nextLong()) +
3✔
66
      Long.toHexString(ThreadLocalRandom.current().nextLong())
4✔
67
    );
68
  }
69

70
  private final StringRedisTemplate redisTemplate;
71
  private final int defaultLockCount;
72
  private final int defaultInquiryCount;
73
  private final int defaultUnlockCount;
74

75
  @Override
76
  public AutoReleaseLock tryLock(String key, long expire, TimeUnit unit) {
77
    return acquireLock(key, expire, unit, defaultLockCount, defaultInquiryCount, defaultUnlockCount);
12✔
78
  }
79

80
  @Override
81
  public AutoReleaseLock tryLock(
82
    String key,
83
    long expire,
84
    TimeUnit unit,
85
    int lockCount,
86
    int inquiryCount,
87
    int unlockCount
88
  ) {
89
    int effectiveLock = lockCount >= 0 ? lockCount : defaultLockCount;
7✔
90
    int effectiveInquiry = inquiryCount >= 0 ? inquiryCount : defaultInquiryCount;
7✔
91
    int effectiveUnlock = unlockCount >= 0 ? unlockCount : defaultUnlockCount;
7✔
92

93
    if (lockCount < 0 || inquiryCount < 0 || unlockCount < 0) {
6!
94
      log.warn(
8✔
95
        "Invalid lock counts [{}, {}, {}], fallback to defaults [{}, {}, {}]",
96
        lockCount,
5✔
97
        inquiryCount,
5✔
98
        unlockCount,
5✔
99
        effectiveLock,
5✔
100
        effectiveInquiry,
5✔
101
        effectiveUnlock
2✔
102
      );
103
    }
104

105
    return acquireLock(key, expire, unit, effectiveLock, effectiveInquiry, effectiveUnlock);
9✔
106
  }
107

108
  /**
109
   * Core lock acquisition logic with exponential backoff between retries.
110
   *
111
   * <p>Algorithm:
112
   * <ol>
113
   *   <li>Atomically {@code SET key uuid NX PX ttl}</li>
114
   *   <li>On transient failure ({@code null}) — single {@code GET} to inquire;
115
   *       if the value is our UUID the lock is considered acquired
116
   *       (optimistic recovery)</li>
117
   *   <li>Between retries, sleep with exponential backoff
118
   *       ({@code min(10ms × 2^i, 100ms)})</li>
119
   * </ol>
120
   *
121
   * @param key           the lock key
122
   * @param expire        lock TTL duration
123
   * @param unit          time unit for {@code expire}
124
   * @param lockCount     SET NX retries
125
   * @param inquiryCount  GET inquiries after transient failure;
126
   *                      continues retrying while {@code value == null}
127
   * @param unlockCount   DEL retries on release
128
   * @return a handle if acquired, or {@code null} on failure
129
   */
130
  private AutoReleaseLock acquireLock(
131
    String key,
132
    long expire,
133
    TimeUnit unit,
134
    int lockCount,
135
    int inquiryCount,
136
    int unlockCount
137
  ) {
138
    if (invalidCacheKey(key)) {
3✔
139
      return null;
2✔
140
    }
141

142
    String lockKey = HotKeyConstants.REDIS_LOCK_KEY_PREFIX + key;
3✔
143
    String uuid = lockId();
2✔
144
    long expireMs = unit.toMillis(expire);
4✔
145
    long expireTimestamp = System.currentTimeMillis() + expireMs;
4✔
146

147
    for (int i = 0; i < lockCount; i++) {
7✔
148
      if (i > 0) {
2✔
149
        try {
150
          Thread.sleep(Math.min(10L * (1L << i), 100L));
8✔
NEW
151
        } catch (InterruptedException e) {
×
NEW
152
          Thread.currentThread().interrupt();
×
NEW
153
          return null;
×
154
        }
1✔
155
      }
156

157
      Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, uuid, Duration.ofMillis(expireMs));
9✔
158

159
      if (Boolean.TRUE.equals(acquired)) {
4✔
160
        if (i > 0) {
2✔
161
          log.debug("Lock '{}' acquired after {} retries", key, i);
6✔
162
        }
163
        return new RedisLockHandle(redisTemplate, lockKey, uuid, expireTimestamp, unlockCount);
10✔
164
      }
165

166
      if (acquired == null) {
2✔
167
        for (int j = 0; j < inquiryCount; j++) {
7✔
168
          String value = redisTemplate.opsForValue().get(lockKey);
7✔
169

170
          if (uuid.equals(value)) {
4✔
171
            if (i > 0) {
2!
NEW
172
              log.debug("Lock '{}' acquired after {} retries (inquiry path)", key, i);
×
173
            }
174
            return new RedisLockHandle(redisTemplate, lockKey, uuid, expireTimestamp, unlockCount);
10✔
175
          }
176
          if (value != null) {
2✔
177
            break;
1✔
178
          }
179
        }
180
      }
181
    }
182
    log.warn("Failed to acquire lock '{}' after {} attempts", key, lockCount);
6✔
183
    return null;
2✔
184
  }
185

186
  @RequiredArgsConstructor
187
  @Slf4j
4✔
188
  static class RedisLockHandle implements AutoReleaseLock {
189

190
    private final StringRedisTemplate redisTemplate;
191
    private final String lockKey;
192
    private final String uuid;
193
    private final long expireTimestamp;
194
    private final int unlockCount;
195

196
    /**
197
     * Release the lock atomically.
198
     *
199
     * <p>Uses Lua {@code GET + DEL} to ensure only the lock owner can
200
     * delete the key.  Retries up to {@code unlockCount} times on
201
     * transient Redis failures.  Silently skips when the lock has
202
     * already expired (Redis TTL handles cleanup).
203
     */
204
    @Override
205
    public void close() {
206
      for (int i = 0; i < unlockCount; i++) {
8✔
207
        if (System.currentTimeMillis() < expireTimestamp) {
5✔
208
          if (i > 0) {
2✔
209
            try {
210
              Thread.sleep(1L);
2✔
NEW
211
            } catch (InterruptedException e) {
×
NEW
212
              Thread.currentThread().interrupt();
×
NEW
213
              return;
×
214
            }
1✔
215
          }
216
          try {
217
            Long result = redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), uuid);
16✔
218
            if (Objects.equals(result, 1L)) {
5✔
219
              return;
1✔
220
            }
221
          } catch (Exception e) {
1✔
222
            log.warn("Redis unlock attempt {}/{} failed for key {}", i + 1, unlockCount, lockKey, e);
27✔
223
          }
2✔
224
        } else {
225
          return;
1✔
226
        }
227
      }
228
      log.warn("Failed to release lock '{}' after {} attempts; lock may persist until TTL", lockKey, unlockCount);
8✔
229
    }
1✔
230
  }
231
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc