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

Hyshmily / hotkey / 28738321635

05 Jul 2026 10:51AM UTC coverage: 90.457% (+0.04%) from 90.421%
28738321635

push

github

Hyshmily
refactor: extract interfaces for 12 core classes (Batch 1+2)

Extract interfaces from concrete classes, impls moved to impl/ subpackages:

Batch 1 (HotPath):
- CircuitBreaker, ExpireManager, SingleFlight
- KeyReporter, RuleMatcher, HealthView

Batch 2 (Supporting):
- VersionController, RingManager, HotKeyStateMachine
- SystemLoadMonitor, BbrRateLimiter, SreRateLimiter

Pattern: interface at original package, impl named XxxImpl under impl/

1296 of 1498 branches covered (86.52%)

Branch coverage included in aggregate %.

448 of 480 new or added lines in 19 files covered. (93.33%)

3690 of 4014 relevant lines covered (91.93%)

4.19 hits per line

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

91.79
/common/src/main/java/io/github/hyshmily/hotkey/rule/impl/RuleMatcherImpl.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.rule.impl;
17

18
import static io.github.hyshmily.hotkey.constants.HotKeyConstants.REDIS_KEY_RULES;
19

20
import com.fasterxml.jackson.core.JsonProcessingException;
21
import com.fasterxml.jackson.core.type.TypeReference;
22
import com.fasterxml.jackson.databind.DeserializationFeature;
23
import com.fasterxml.jackson.databind.ObjectMapper;
24
import io.github.hyshmily.hotkey.Internal;
25
import io.github.hyshmily.hotkey.rule.Rule;
26
import io.github.hyshmily.hotkey.rule.Rule.RuleAction;
27
import io.github.hyshmily.hotkey.rule.RuleMatcher;
28
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
29
import jakarta.annotation.PostConstruct;
30
import java.util.*;
31
import java.util.concurrent.CopyOnWriteArrayList;
32
import java.util.concurrent.atomic.AtomicLong;
33
import lombok.RequiredArgsConstructor;
34
import lombok.extern.slf4j.Slf4j;
35
import org.springframework.data.redis.core.StringRedisTemplate;
36
import org.springframework.data.redis.core.script.DefaultRedisScript;
37
import org.springframework.data.redis.core.script.RedisScript;
38

39
/**
40
 * Central component for evaluating cache-key rules that govern blocking,
41
 * reporting suppression, and other access-control decisions.
42
 *
43
 * <p><b>Rule lifecycle:</b> Rules are maintained in a thread-safe
44
 * {@link CopyOnWriteArrayList} and evaluated in insertion order on every
45
 * cache {@code get} / {@code getWithSoftExpire} invocation. The first
46
 * matching rule determines the action.
47
 *
48
 * <p><b>Persistence and synchronization:</b> Persistence is optional. If a
49
 * {@link StringRedisTemplate} is available, the current rule set is written
50
 * to Redis on every local change (via a Lua compare-and-set script that
51
 * guards against stale-write races) and reloaded at startup. If Redis is
52
 * unavailable, rules remain purely in-memory — they survive as long as at
53
 * least one application node holds them and can be re-broadcast to siblings
54
 * through the {@link CacheSyncPublisher}.
55
 *
56
 * <p><b>Cross-instance sync:</b> When a rule is added, removed, or cleared,
57
 * the change is both persisted to Redis <em>and</em> broadcast via AMQP
58
 * (both/and, not XOR). Incoming broadcasts from sibling instances are
59
 * merged via pattern-based merge (incoming overwrites same-pattern local
60
 * rules) and guarded by a monotonically increasing version counter.
61
 *
62
 * <p>Thread-safe. The rule list is a volatile {@link CopyOnWriteArrayList};
63
 * all mutating methods synchronize via the list's snapshot semantics. The
64
 * version counter is an {@link java.util.concurrent.atomic.AtomicLong}.
65
 */
66
@Internal
67
@RequiredArgsConstructor
68
@Slf4j
3✔
69
@SuppressWarnings("all")
70
public class RuleMatcherImpl implements RuleMatcher {
71

72
  /** Shared Jackson mapper; ignores unknown properties for forward compatibility. */
73
  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().configure(
7✔
74
    DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
75
    false
76
  );
77

78
  /** Lua compare-and-set script: writes only if incoming version > current Redis version. */
79
  private static final RedisScript<Long> RULE_CAS_SCRIPT;
80

81
  static {
82
    DefaultRedisScript<Long> s = new DefaultRedisScript<>();
4✔
83
    s.setScriptText(
3✔
84
      """
85
      local current = redis.call('GET', KEYS[1])
86
      if current == false then
87
        redis.call('SET', KEYS[1], ARGV[1])
88
        return 1
89
      end
90
      local ok, decoded = pcall(cjson.decode, current)
91
      local curVer = 0
92
      if ok and type(decoded) == 'table' and decoded['rulesVersion'] then
93
        curVer = decoded['rulesVersion']
94
      end
95
      if tonumber(ARGV[2]) > curVer then
96
        redis.call('SET', KEYS[1], ARGV[1])
97
        return 1
98
      end
99
      return 0"""
100
    );
101
    s.setResultType(Long.class);
3✔
102
    RULE_CAS_SCRIPT = s;
2✔
103
  }
1✔
104

105
  /** Optional Redis template for rule persistence across restarts. */
106
  private final Optional<StringRedisTemplate> redisTemplate;
107
  /** Optional publisher for broadcasting rule changes to sibling instances. */
108
  private final Optional<CacheSyncPublisher> cacheSyncPublisher;
109

110
  /** Thread-safe list of all active rules, evaluated in order. */
111
  private volatile List<Rule> rulesList = new CopyOnWriteArrayList<>();
112

113
  /** Monotonically increasing version for rule set changes. Never degraded. */
114
  private final AtomicLong rulesVersion = new AtomicLong(0L);
115

116
  /**
117
   * Load persisted rules from Redis (if available) at bean initialization time.
118
   *
119
   * <p>Called automatically by the Spring container after dependency injection
120
   * ({@link PostConstruct}). If Redis is not configured or is unreachable,
121
   * the rule list starts empty and the application operates normally with
122
   * out-of-memory-only rules. Any errors during loading are logged but do
123
   * not prevent bean initialization.
124
   *
125
   * @see #loadRulesFromRedis
126
   */
127
  @PostConstruct
128
  void initRules() {
129
    loadRulesFromRedis();
2✔
130
  }
1✔
131

132
  /**
133
   * Append a rule to the end of the rule list.
134
   *
135
   * <p>The change is immediately visible in the local rule list (snapshot
136
   * semantics via {@link CopyOnWriteArrayList}), persisted to Redis via
137
   * Lua compare-and-set (if available), and broadcast to sibling
138
   * application instances via AMQP. The local version counter is
139
   * incremented atomically.
140
   *
141
   * @param rule the rule to append; must not be {@code null}. The rule is
142
   *             {@link Rule#prepare() prepared} before insertion to ensure
143
   *             its internal pattern is compiled
144
   */
145
  public void addRule(Rule rule) {
146
    Objects.requireNonNull(rule, "rule must not be null");
4✔
147
    if (rule.getPattern() == null || rule.getPattern().isEmpty()) {
7✔
148
      throw new IllegalArgumentException("pattern must not be null or empty");
5✔
149
    }
150
    if (rule.getAction() == null) {
3✔
151
      throw new IllegalArgumentException("action must not be null");
5✔
152
    }
153

154
    rule.prepare();
2✔
155
    rulesList.add(rule);
5✔
156
    rulesVersion.incrementAndGet();
4✔
157
    persistAndBroadcastRules();
2✔
158

159
    if (log.isInfoEnabled()) {
3!
160
      log.info(
8✔
161
        "Rule added: pattern='{}', type={}, action={} (total: {}, version: {})",
162
        rule.getPattern(),
5✔
163
        rule.getType(),
5✔
164
        rule.getAction(),
6✔
165
        rulesList.size(),
7✔
166
        rulesVersion.get()
3✔
167
      );
168
    }
169
  }
1✔
170

171
  /**
172
   * Remove all rules whose pattern and action both match the given values.
173
   *
174
   * <p>If at least one rule is removed, the change is persisted to Redis
175
   * and broadcast to sibling instances. The version counter is incremented.
176
   *
177
   * <p>Matching is based on exact equality of both {@code pattern} and
178
   * {@code action}; rules with the same pattern but a different action
179
   * are <em>not</em> removed.
180
   *
181
   * @param pattern the pattern string to match ({@link Rule#pattern})
182
   * @param action  the action to match ({@link Rule#action})
183
   * @return {@code true} if at least one rule was removed; {@code false}
184
   *         if no matching rule was found
185
   */
186
  public boolean removeRule(String pattern, RuleAction action) {
187
    boolean removed = rulesList.removeIf(
7✔
188
      existing -> existing.getPattern().equals(pattern) && existing.getAction() == action
13✔
189
    );
190
    if (removed) {
2✔
191
      rulesVersion.incrementAndGet();
4✔
192
      persistAndBroadcastRules();
2✔
193
      log.info(
18✔
194
        "Rule removed: pattern='{}', action={} (total: {}, version: {})",
195
        pattern,
196
        action,
197
        rulesList.size(),
7✔
198
        rulesVersion.get()
3✔
199
      );
200
    } else {
201
      log.warn("Rule not found for removal: pattern='{}', action={}", pattern, action);
5✔
202
    }
203
    return removed;
2✔
204
  }
205

206
  /**
207
   * Remove all rules and propagate the empty rule list.
208
   *
209
   * <p>After clearing, the rule list is empty, the version counter is
210
   * incremented, and the empty list is persisted to Redis and broadcast
211
   * to sibling instances. This ensures that all nodes converge to the
212
   * empty state.
213
   */
214
  public void clearRules() {
215
    int before = rulesList.size();
4✔
216
    rulesList.clear();
3✔
217
    rulesVersion.incrementAndGet();
4✔
218

219
    persistAndBroadcastRules();
2✔
220
    log.info("All rules cleared (removed: {}, version: {})", before, rulesVersion.get());
9✔
221
  }
1✔
222

223
  /**
224
   * Remove all rules whose action equals the given value.
225
   *
226
   * <p>If any rules are removed, the change is persisted and broadcast.
227
   * The version counter is incremented with each batch removal.
228
   *
229
   * @param action the action to match; all rules with this action are
230
   *               removed regardless of their pattern
231
   * @return the number of rules removed (zero if no matching rules exist)
232
   */
233
  public int removeRulesByAction(RuleAction action) {
234
    int before = rulesList.size();
4✔
235
    rulesList.removeIf(r -> r.getAction() == action);
14✔
236
    int removed = before - rulesList.size();
6✔
237

238
    if (removed > 0) {
2✔
239
      rulesVersion.incrementAndGet();
4✔
240
      persistAndBroadcastRules();
2✔
241
      log.info(
12✔
242
        "Rules removed by action: {} (removed: {}, total: {}, version: {})",
243
        action,
244
        removed,
6✔
245
        rulesList.size(),
7✔
246
        rulesVersion.get()
3✔
247
      );
248
    }
249
    return removed;
2✔
250
  }
251

252
  /**
253
   * Remove the rule at the given index in the current rule list.
254
   *
255
   * <p>If the index is valid (non-negative and less than the current list
256
   * size), the rule is removed and the change is persisted and broadcast.
257
   * Out-of-bounds indices are silently ignored (logged at DEBUG level
258
   * by the caller).
259
   *
260
   * @param index the zero-based index of the rule to remove
261
   */
262
  public void removeRule(int index) {
263
    if (index >= 0 && index < rulesList.size()) {
7✔
264
      Rule removed = rulesList.remove(index);
6✔
265
      rulesVersion.incrementAndGet();
4✔
266
      persistAndBroadcastRules();
2✔
267
      log.info(
8✔
268
        "Rule removed by index {}: pattern='{}', action={} (total: {}, version: {})",
269
        index,
5✔
270
        removed.getPattern(),
5✔
271
        removed.getAction(),
6✔
272
        rulesList.size(),
7✔
273
        rulesVersion.get()
3✔
274
      );
275
    }
276
  }
1✔
277

278
  /**
279
   * Merge the incoming rule set into the local rule set, guarded by version.
280
   * <p>
281
   * If {@code incomingVersion > 0} and {@code <= localVersion}, the sync is skipped
282
   * (stale broadcast). Otherwise the rules are merged by pattern (incoming overwrites
283
   * same-pattern entries, local-only entries are preserved), the local version is
284
   * bumped past the incoming version, and the result is persisted to Redis.
285
   * <p>
286
   * This method <b>does not broadcast</b> — only updates local list and Redis,
287
   * avoiding a broadcast storm.
288
   *
289
   * @param json            the JSON-serialized rule list (may be old-format array or
290
   *                        new-format wrapper)
291
   * @param incomingVersion the rulesVersion from the incoming message header,
292
   *                        or {@code 0L} for pre-version broadcasts
293
   */
294
  public void syncRules(String json, long incomingVersion) {
295
    if (incomingVersion > 0L && incomingVersion <= rulesVersion.get()) {
10!
NEW
296
      log.debug("Stale rules sync ignored: incomingVersion={}, localVersion={}", incomingVersion, rulesVersion.get());
×
NEW
297
      return;
×
298
    }
299
    try {
300
      List<Rule> incomingRules = parseRulesJson(json);
3✔
301
      List<Rule> merged = mergeRules(rulesList, incomingRules);
5✔
302

303
      replaceRules(merged);
3✔
304

305
      long newVersion = rulesVersion.updateAndGet(v -> Math.max(v, incomingVersion)) + 1;
12✔
306

307
      String outJson = serializeRules(merged, newVersion);
5✔
308
      redisTemplate.ifPresent(r -> persistToRedis(r, outJson, newVersion));
13✔
309
      log.info("Rules synced from broadcast, version={}, total: {}", newVersion, merged.size());
8✔
310
    } catch (Exception e) {
1✔
311
      log.error("Failed to sync rules from broadcast", e);
4✔
312
    }
1✔
313
  }
1✔
314

315
  /**
316
   * Atomically swap the rule list.  Does <b>not</b> persist or broadcast.
317
   * <p>
318
   * Primarily used internally by {@link #syncRules} and for tests.
319
   *
320
   * @param newRules the new rule list; each element is {@link Rule#prepare() prepared} before
321
   *     insertion, must not be {@code null}
322
   */
323
  public void replaceRules(List<Rule> newRules) {
324
    int oldSize = rulesList.size();
4✔
325
    List<Rule> replacement = new CopyOnWriteArrayList<>();
4✔
326
    newRules.forEach(r -> {
4✔
327
      r.prepare();
2✔
328
      replacement.add(r);
4✔
329
    });
1✔
330
    rulesList = replacement;
3✔
331
    log.info("Rules replaced: {} -> {} rules", oldSize, replacement.size());
8✔
332
  }
1✔
333

334
  /** @return a snapshot of the current rules (safe for iteration). */
335
  public List<Rule> getAllRules() {
336
    return new ArrayList<>(rulesList);
6✔
337
  }
338

339
  /**
340
   * Evaluate the rule set for the given key and return an {@link Optional}
341
   * that describes how the caller should behave.
342
   * <ul>
343
   *   <li>{@code Optional.empty()} – {@code BLOCK}: reject the access immediately.</li>
344
   *   <li>{@code Optional.of(true)} – {@code ALLOW_NO_REPORT}: allow the access
345
   *       but suppress the hot‑key report.</li>
346
   *   <li>{@code Optional.of(false)} – no matching rule or {@code ALLOW}:
347
   *       proceed normally with reporting.</li>
348
   * </ul>
349
   *
350
   * @param cacheKey  the key being accessed
351
   * @param operation a label used only in log messages (e.g. "get", "getWithSoftExpire")
352
   * @return {@link Optional#empty()} if the rule action is {@link RuleAction#BLOCK},
353
   *         {@code Optional.of(true)} if the rule action is {@link RuleAction#ALLOW_NO_REPORT},
354
   *         {@code Optional.of(false)} if no rule matches or the action is {@link RuleAction#ALLOW}
355
   */
356
  public Optional<Boolean> isAllowNoReport(String cacheKey, String operation) {
357
    RuleAction action = evaluateRule(cacheKey);
4✔
358
    if (action == RuleAction.BLOCK) {
3✔
359
      log.debug("{}: blocked by rule: {}", operation, cacheKey);
5✔
360
      return Optional.empty();
2✔
361
    }
362
    return Optional.of(action == RuleAction.ALLOW_NO_REPORT);
9✔
363
  }
364

365
  /**
366
   * Walk the rule list in order; the first match wins.
367
   *
368
   * @param cacheKey the key to evaluate against all rules
369
   * @return the {@link RuleAction} of the first matching rule, or {@link RuleAction#ALLOW} if no rule matches
370
   */
371
  public RuleAction evaluateRule(String cacheKey) {
372
    for (Rule rule : rulesList) {
11✔
373
      if (rule.match(cacheKey)) {
4✔
374
        return rule.getAction();
3✔
375
      }
376
    }
1✔
377
    return RuleAction.ALLOW;
2✔
378
  }
379

380
  /**
381
   * Serialize the current rule list to the versioned JSON format, write it to Redis
382
   * (if available) via Lua compare-and-set, and broadcast it via AMQP (if available).
383
   * <p>
384
   * Both channels are invoked independently — the XOR pattern has been replaced
385
   * by both/and. Failures are logged but never propagated.
386
   */
387
  private void persistAndBroadcastRules() {
388
    try {
389
      long version = rulesVersion.get();
4✔
390
      String json = serializeRules(new ArrayList<>(rulesList), version);
9✔
391

392
      redisTemplate.ifPresent(r -> persistToRedis(r, json, version));
13✔
393
      cacheSyncPublisher.ifPresent(p -> p.broadcastAllLocalRules(json, version));
11✔
394

395
      log.debug("Rules persisted (version={}), total: {}", version, rulesList.size());
9✔
NEW
396
    } catch (Exception e) {
×
NEW
397
      log.error("Failed to persist rules", e);
×
398
    }
1✔
399
  }
1✔
400

401
  /**
402
   * Write the versioned JSON rule set to Redis via Lua compare-and-set.
403
   * Falls back to plain SET if the Lua script fails.
404
   */
405
  private void persistToRedis(StringRedisTemplate r, String json, long version) {
406
    try {
407
      r.execute(RULE_CAS_SCRIPT, List.of(REDIS_KEY_RULES), json, String.valueOf(version));
17✔
NEW
408
    } catch (Exception e) {
×
NEW
409
      log.warn("Lua compare-and-set failed for rules (version={}), fallback to plain set", version, e);
×
410
      try {
NEW
411
        r.opsForValue().set(REDIS_KEY_RULES, json);
×
NEW
412
      } catch (Exception e2) {
×
NEW
413
        log.error("Redis fallback set also failed, skipping persist", e2);
×
NEW
414
      }
×
415
    }
1✔
416
  }
1✔
417

418
  /**
419
   * Serialize a rule list and its associated version into the versioned JSON
420
   * wrapper format.
421
   *
422
   * <p>The output format is a JSON object with two top-level keys:
423
   * <pre>{@code {"rulesVersion":5,"rules":[{"pattern":"x","action":"BLOCK"}]}}</pre>
424
   *
425
   * <p>This wrapper format is used for both Redis persistence and AMQP
426
   * broadcast, enabling the receiver to apply version-gated merge logic.
427
   *
428
   * @param rules   the rule list to serialize (a snapshot, typically via
429
   *                {@link #getAllRules()})
430
   * @param version the current rules version to embed in the wrapper
431
   * @return the JSON string representation
432
   * @throws JsonProcessingException if Jackson serialization fails
433
   */
434
  private String serializeRules(List<Rule> rules, long version) throws JsonProcessingException {
435
    Map<String, Object> wrapper = new LinkedHashMap<>();
4✔
436
    wrapper.put("rulesVersion", version);
6✔
437
    wrapper.put("rules", rules);
5✔
438

439
    return OBJECT_MAPPER.writeValueAsString(wrapper);
4✔
440
  }
441

442
  /**
443
   * Merge two rule lists by pattern identity ({@link Rule#getPattern()}).
444
   *
445
   * <p>The merge semantics follow a "last writer wins" strategy on a per-pattern
446
   * basis: for each pattern in the incoming list, the incoming rule replaces
447
   * any local rule with the same pattern. Local rules whose patterns are not
448
   * present in the incoming list are preserved. The order is determined by
449
   * insertion order in the merged map (local rules first, then incoming rules
450
   * overwrite/appending at the end).
451
   *
452
   * <p>This strategy ensures that a rule removal on one node is not
453
   * reintroduced by a stale broadcast from another node: if the incoming set
454
   * does not contain a pattern, the local version is retained.
455
   *
456
   * @param local    the current local rule list
457
   * @param incoming the incoming rule list from a broadcast or Redis load
458
   * @return a new merged rule list combining both sources
459
   */
460
  private static List<Rule> mergeRules(List<Rule> local, List<Rule> incoming) {
461
    Map<String, Rule> map = new LinkedHashMap<>();
4✔
462
    for (Rule rule : local) {
10✔
463
      map.put(rule.getPattern(), rule);
6✔
464
    }
1✔
465
    for (Rule rule : incoming) {
10✔
466
      map.put(rule.getPattern(), rule);
6✔
467
    }
1✔
468
    return new ArrayList<>(map.values());
6✔
469
  }
470

471
  /**
472
   * Parse a JSON string into a {@code List<Rule>}, supporting both the
473
   * legacy array format and the current versioned wrapper format.
474
   *
475
   * <p><b>Legacy format:</b> a plain JSON array of rule objects, e.g.
476
   * <pre>{@code [{"pattern":"x","action":"BLOCK"}]}</pre>
477
   *
478
   * <p><b>Current format:</b> a JSON object with a {@code "rules"} key
479
   * containing the array, e.g.
480
   * <pre>{@code {"rulesVersion":5,"rules":[{"pattern":"x","action":"BLOCK"}]}}</pre>
481
   *
482
   * <p>The format is auto-detected by examining the first character:
483
   * {@code [} indicates legacy array format, {@code \{} indicates the
484
   * versioned wrapper. Backward compatibility is maintained to support
485
   * rolling upgrades.
486
   *
487
   * @param json the JSON string to parse; may be {@code null} or empty
488
   *             (in which case an empty list is returned upstream by the
489
   *             caller's null check)
490
   * @return the parsed list of rules; never {@code null} (returns an
491
   *         empty list if the wrapper format lacks a {@code "rules"} key
492
   *         or if the value is not an array)
493
   * @throws JsonProcessingException if the JSON is malformed
494
   */
495
  private static List<Rule> parseRulesJson(String json) throws JsonProcessingException {
496
    String trimmed = json.trim();
3✔
497
    if (trimmed.startsWith("[")) {
4✔
498
      return OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
11✔
499
    }
500

501
    Map<String, Object> wrapper = OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
11✔
502
    Object raw = wrapper.get("rules");
4✔
503
    if (raw instanceof List<?>) {
3!
504
      return OBJECT_MAPPER.convertValue(raw, new TypeReference<>() {});
11✔
505
    }
NEW
506
    return List.of();
×
507
  }
508

509
  /**
510
   * Explicitly reload rules from Redis and broadcast the full rule set to
511
   * all sibling application instances via AMQP.
512
   *
513
   * <p>This method is useful for operational scenarios such as:
514
   * <ul>
515
   *   <li>An operator manually adjusted rules on one node and wants the
516
   *       entire cluster to converge immediately</li>
517
   *   <li>Recovering from a missed broadcast during a temporary network
518
   *       partition</li>
519
   *   <li>Initial cluster synchronization after a new node joins</li>
520
   * </ul>
521
   *
522
   * <p>The rule list is first reloaded from Redis (to pick up any
523
   * externally written changes), then serialized with the current version
524
   * and broadcast. If no {@link CacheSyncPublisher} is configured, the
525
   * method logs a warning and returns without broadcasting.
526
   */
527
  public void broadcastAllLocalRulesManually() {
528
    loadRulesFromRedis();
2✔
529

530
    if (cacheSyncPublisher.isPresent()) {
4✔
531
      try {
532
        long version = rulesVersion.get();
4✔
533
        String json = serializeRules(new ArrayList<>(rulesList), version);
9✔
534
        cacheSyncPublisher.get().broadcastAllLocalRules(json, version);
7✔
535

536
        log.info("Rules broadcast manually (version={}), total: {}", version, rulesList.size());
9✔
NEW
537
      } catch (Exception e) {
×
NEW
538
        log.error("Failed to serialize rules", e);
×
539
      }
1✔
540
    }
541
  }
1✔
542

543
  /**
544
   * Load persisted rules from Redis (if available) and atomically replace
545
   * the local rule list with the loaded rules.
546
   *
547
   * <p>If Redis is not configured, this method is a no-op. If the Redis
548
   * fetch or JSON parsing fails, the error is logged and the current
549
   * in-memory rule list is preserved — the application continues operating
550
   * with the existing rules.
551
   *
552
   * <p>This method is called at startup via {@link #initRules()} and can
553
   * also be invoked manually via {@link #broadcastAllLocalRulesManually()}
554
   * to refresh from Redis on demand.
555
   */
556
  private void loadRulesFromRedis() {
557
    redisTemplate.ifPresent(r -> {
5✔
558
      try {
559
        String json = r.opsForValue().get(REDIS_KEY_RULES);
6✔
560

561
        if (json == null || json.isEmpty()) {
5✔
562
          log.info("No rules found in Redis, starting with empty rule set");
3✔
563
          return;
1✔
564
        }
565

566
        List<Rule> saved = parseRulesJson(json);
3✔
567
        replaceRules(saved);
3✔
568

569
        log.info("Rules loaded from Redis: {} rules", saved.size());
6✔
570
      } catch (Exception e) {
1✔
571
        log.error("Failed to load rules from Redis", e);
4✔
572
      }
1✔
573
    });
1✔
574
  }
1✔
575
}
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