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

Hyshmily / hotkey / 28356107387

29 Jun 2026 07:35AM UTC coverage: 91.168% (-0.01%) from 91.178%
28356107387

push

github

Hyshmily
test : fix CI tests

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

1276 of 1461 branches covered (87.34%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 4 files covered. (100.0%)

117 existing lines in 15 files now uncovered.

3596 of 3883 relevant lines covered (92.61%)

4.24 hits per line

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

91.16
/common/src/main/java/io/github/hyshmily/hotkey/rule/RuleMatcher.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;
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.rule.Rule.RuleAction;
25
import io.github.hyshmily.hotkey.sync.local.CacheSyncPublisher;
26
import jakarta.annotation.PostConstruct;
27
import java.util.*;
28
import java.util.concurrent.CopyOnWriteArrayList;
29
import java.util.concurrent.atomic.AtomicLong;
30
import lombok.RequiredArgsConstructor;
31
import lombok.extern.slf4j.Slf4j;
32
import org.springframework.data.redis.core.StringRedisTemplate;
33
import org.springframework.data.redis.core.script.DefaultRedisScript;
34
import org.springframework.data.redis.core.script.RedisScript;
35

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

67
  /** Shared Jackson mapper; ignores unknown properties for forward compatibility. */
68
  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().configure(
7✔
69
    DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
70
    false
71
  );
72

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

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

100
  /** Optional Redis template for rule persistence across restarts. */
101
  private final Optional<StringRedisTemplate> redisTemplate;
102
  /** Optional publisher for broadcasting rule changes to sibling instances. */
103
  private final Optional<CacheSyncPublisher> cacheSyncPublisher;
104

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

108
  /** Monotonically increasing version for rule set changes. Never degraded. */
109
  private final AtomicLong rulesVersion = new AtomicLong(0L);
110

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

127
  /**
128
   * Convenience factory method that auto-detects the appropriate
129
   * {@link Rule.RuleType} from the pattern string.
130
   *
131
   * <p>Type auto-detection logic:
132
   * <pre>{@code
133
   * RuleMatcher.of("user:123", BLOCK)                  -> EXACT
134
   * RuleMatcher.of("temp:*", BLOCK)                   -> PREFIX (single trailing '*')
135
   * RuleMatcher.of("order:*-detail", ALLOW_NO_REPORT)  -> WILDCARD (interior wildcard)
136
   * RuleMatcher.of("regex:user:\\d+", BLOCK)           -> REGEX (detected via 'regex:' prefix)
137
   * }</pre>
138
   *
139
   * <p>The {@code regex:} prefix is stripped before creating the rule, so
140
   * the stored pattern is the pure regex without the prefix.
141
   *
142
   * @param pattern the key pattern string; detection rules are:
143
   *     <ul>
144
   *       <li>A leading {@code regex:} prefix forces {@link Rule.RuleType#REGEX}</li>
145
   *       <li>A single trailing {@code *} without interior wildcards or
146
   *           {@code ?} is optimized to {@link Rule.RuleType#PREFIX}</li>
147
   *       <li>Patterns containing {@code *} or {@code ?} are treated as
148
   *           {@link Rule.RuleType#WILDCARD}</li>
149
   *       <li>Otherwise the pattern is treated as {@link Rule.RuleType#EXACT}</li>
150
   *     </ul>
151
   * @param action  the action to assign to the created rule
152
   * @return a new {@link Rule} with auto-detected type, unique ID, and
153
   *         creation timestamp
154
   * @throws java.util.regex.PatternSyntaxException if the {@code regex:}
155
   *         prefix is used and the pattern is not a valid regular expression
156
   *         (propagated from the {@link Rule} constructor)
157
   */
158
  public static Rule of(String pattern, RuleAction action) {
159
    if (pattern.startsWith("regex:")) {
4✔
160
      return new Rule(Rule.RuleType.REGEX, pattern.substring(6), action);
9✔
161
    }
162
    if (pattern.contains("*") || pattern.contains("?")) {
8✔
163
      // A single trailing '*' with no '?' -> PREFIX optimization
164
      if (pattern.endsWith("*") && pattern.indexOf('*') == pattern.length() - 1 && !pattern.contains("?")) {
16!
165
        return new Rule(Rule.RuleType.PREFIX, pattern.substring(0, pattern.length() - 1), action);
13✔
166
      }
167
      return new Rule(Rule.RuleType.WILDCARD, pattern, action);
7✔
168
    }
169
    return new Rule(Rule.RuleType.EXACT, pattern, action);
7✔
170
  }
171

172
  /**
173
   * Append a rule to the end of the rule list.
174
   *
175
   * <p>The change is immediately visible in the local rule list (snapshot
176
   * semantics via {@link CopyOnWriteArrayList}), persisted to Redis via
177
   * Lua compare-and-set (if available), and broadcast to sibling
178
   * application instances via AMQP. The local version counter is
179
   * incremented atomically.
180
   *
181
   * @param rule the rule to append; must not be {@code null}. The rule is
182
   *             {@link Rule#prepare() prepared} before insertion to ensure
183
   *             its internal pattern is compiled
184
   */
185
  public void addRule(Rule rule) {
186
    Objects.requireNonNull(rule, "rule must not be null");
4✔
187
    if (rule.getPattern() == null || rule.getPattern().isEmpty()) {
7✔
188
      throw new IllegalArgumentException("pattern must not be null or empty");
5✔
189
    }
190
    if (rule.getAction() == null) {
3✔
191
      throw new IllegalArgumentException("action must not be null");
5✔
192
    }
193

194
    rule.prepare();
2✔
195
    rulesList.add(rule);
5✔
196
    rulesVersion.incrementAndGet();
4✔
197
    persistAndBroadcastRules();
2✔
198

199
    if (log.isInfoEnabled()) {
3!
200
      log.info(
8✔
201
        "Rule added: pattern='{}', type={}, action={} (total: {}, version: {})",
202
        rule.getPattern(), rule.getType(), rule.getAction(), rulesList.size(), rulesVersion.get());
26✔
203
    }
204
  }
1✔
205

206
  /**
207
   * Remove all rules whose pattern and action both match the given values.
208
   *
209
   * <p>If at least one rule is removed, the change is persisted to Redis
210
   * and broadcast to sibling instances. The version counter is incremented.
211
   *
212
   * <p>Matching is based on exact equality of both {@code pattern} and
213
   * {@code action}; rules with the same pattern but a different action
214
   * are <em>not</em> removed.
215
   *
216
   * @param pattern the pattern string to match ({@link Rule#pattern})
217
   * @param action  the action to match ({@link Rule#action})
218
   * @return {@code true} if at least one rule was removed; {@code false}
219
   *         if no matching rule was found
220
   */
221
  public boolean removeRule(String pattern, RuleAction action) {
222
    boolean removed = rulesList.removeIf(
7✔
223
      existing -> existing.getPattern().equals(pattern) && existing.getAction() == action
13✔
224
    );
225
    if (removed) {
2✔
226
      rulesVersion.incrementAndGet();
4✔
227
      persistAndBroadcastRules();
2✔
228
      log.info(
18✔
229
        "Rule removed: pattern='{}', action={} (total: {}, version: {})",
230
        pattern, action, rulesList.size(), rulesVersion.get());
10✔
231
    } else {
232
      log.warn("Rule not found for removal: pattern='{}', action={}", pattern, action);
5✔
233
    }
234
    return removed;
2✔
235
  }
236

237
  /**
238
   * Remove all rules and propagate the empty rule list.
239
   *
240
   * <p>After clearing, the rule list is empty, the version counter is
241
   * incremented, and the empty list is persisted to Redis and broadcast
242
   * to sibling instances. This ensures that all nodes converge to the
243
   * empty state.
244
   */
245
  public void clearRules() {
246
    int before = rulesList.size();
4✔
247
    rulesList.clear();
3✔
248
    rulesVersion.incrementAndGet();
4✔
249

250
    persistAndBroadcastRules();
2✔
251
    log.info("All rules cleared (removed: {}, version: {})", before, rulesVersion.get());
9✔
252
  }
1✔
253

254
  /**
255
   * Remove all rules whose action equals the given value.
256
   *
257
   * <p>If any rules are removed, the change is persisted and broadcast.
258
   * The version counter is incremented with each batch removal.
259
   *
260
   * @param action the action to match; all rules with this action are
261
   *               removed regardless of their pattern
262
   * @return the number of rules removed (zero if no matching rules exist)
263
   */
264
  public int removeRulesByAction(RuleAction action) {
265
    int before = rulesList.size();
4✔
266
    rulesList.removeIf(r -> r.getAction() == action);
14✔
267
    int removed = before - rulesList.size();
6✔
268

269
    if (removed > 0) {
2✔
270
      rulesVersion.incrementAndGet();
4✔
271
      persistAndBroadcastRules();
2✔
272
      log.info(
12✔
273
        "Rules removed by action: {} (removed: {}, total: {}, version: {})",
274
        action, removed, rulesList.size(), rulesVersion.get());
16✔
275
    }
276
    return removed;
2✔
277
  }
278

279
  /**
280
   * Remove the rule at the given index in the current rule list.
281
   *
282
   * <p>If the index is valid (non-negative and less than the current list
283
   * size), the rule is removed and the change is persisted and broadcast.
284
   * Out-of-bounds indices are silently ignored (logged at DEBUG level
285
   * by the caller).
286
   *
287
   * @param index the zero-based index of the rule to remove
288
   */
289
  public void removeRule(int index) {
290
    if (index >= 0 && index < rulesList.size()) {
7✔
291
      Rule removed = rulesList.remove(index);
6✔
292
      rulesVersion.incrementAndGet();
4✔
293
      persistAndBroadcastRules();
2✔
294
      log.info(
8✔
295
        "Rule removed by index {}: pattern='{}', action={} (total: {}, version: {})",
296
        index, removed.getPattern(), removed.getAction(), rulesList.size(), rulesVersion.get());
26✔
297
    }
298
  }
1✔
299

300
  /**
301
   * Merge the incoming rule set into the local rule set, guarded by version.
302
   * <p>
303
   * If {@code incomingVersion > 0} and {@code <= localVersion}, the sync is skipped
304
   * (stale broadcast). Otherwise the rules are merged by pattern (incoming overwrites
305
   * same-pattern entries, local-only entries are preserved), the local version is
306
   * bumped past the incoming version, and the result is persisted to Redis.
307
   * <p>
308
   * This method <b>does not broadcast</b> — only updates local list and Redis,
309
   * avoiding a broadcast storm.
310
   *
311
   * @param json            the JSON-serialized rule list (may be old-format array or
312
   *                        new-format wrapper)
313
   * @param incomingVersion the rulesVersion from the incoming message header,
314
   *                        or {@code 0L} for pre-version broadcasts
315
   */
316
  public void syncRules(String json, long incomingVersion) {
317
    if (incomingVersion > 0L && incomingVersion <= rulesVersion.get()) {
10!
UNCOV
318
      log.debug("Stale rules sync ignored: incomingVersion={}, localVersion={}", incomingVersion, rulesVersion.get());
×
UNCOV
319
      return;
×
320
    }
321
    try {
322
      List<Rule> incomingRules = parseRulesJson(json);
3✔
323
      List<Rule> merged = mergeRules(rulesList, incomingRules);
5✔
324

325
      replaceRules(merged);
3✔
326

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

329
      String outJson = serializeRules(merged, newVersion);
5✔
330
      redisTemplate.ifPresent(r -> persistToRedis(r, outJson, newVersion));
13✔
331
      log.info("Rules synced from broadcast, version={}, total: {}", newVersion, merged.size());
8✔
332
    } catch (Exception e) {
1✔
333
      log.error("Failed to sync rules from broadcast", e);
4✔
334
    }
1✔
335
  }
1✔
336

337
  /**
338
   * Atomically swap the rule list.  Does <b>not</b> persist or broadcast.
339
   * <p>
340
   * Primarily used internally by {@link #syncRules} and for tests.
341
   *
342
   * @param newRules the new rule list; each element is {@link Rule#prepare() prepared} before
343
   *     insertion, must not be {@code null}
344
   */
345
  public void replaceRules(List<Rule> newRules) {
346
    int oldSize = rulesList.size();
4✔
347
    List<Rule> replacement = new CopyOnWriteArrayList<>();
4✔
348
    newRules.forEach(r -> {
4✔
349
      r.prepare();
2✔
350
      replacement.add(r);
4✔
351
    });
1✔
352
    rulesList = replacement;
3✔
353
    log.info("Rules replaced: {} -> {} rules", oldSize, replacement.size());
8✔
354
  }
1✔
355

356
  /** @return a snapshot of the current rules (safe for iteration). */
357
  public List<Rule> getAllRules() {
358
    return new ArrayList<>(rulesList);
6✔
359
  }
360

361
  /**
362
   * Evaluate the rule set for the given key and return an {@link Optional}
363
   * that describes how the caller should behave.
364
   * <ul>
365
   *   <li>{@code Optional.empty()} – {@code BLOCK}: reject the access immediately.</li>
366
   *   <li>{@code Optional.of(true)} – {@code ALLOW_NO_REPORT}: allow the access
367
   *       but suppress the hot‑key report.</li>
368
   *   <li>{@code Optional.of(false)} – no matching rule or {@code ALLOW}:
369
   *       proceed normally with reporting.</li>
370
   * </ul>
371
   *
372
   * @param cacheKey  the key being accessed
373
   * @param operation a label used only in log messages (e.g. "get", "getWithSoftExpire")
374
   * @return {@link Optional#empty()} if the rule action is {@link RuleAction#BLOCK},
375
   *         {@code Optional.of(true)} if the rule action is {@link RuleAction#ALLOW_NO_REPORT},
376
   *         {@code Optional.of(false)} if no rule matches or the action is {@link RuleAction#ALLOW}
377
   */
378
  public Optional<Boolean> isAllowNoReport(String cacheKey, String operation) {
379
    RuleAction action = evaluateRule(cacheKey);
4✔
380
    if (action == RuleAction.BLOCK) {
3✔
381
      log.debug("{}: blocked by rule: {}", operation, cacheKey);
5✔
382
      return Optional.empty();
2✔
383
    }
384
    return Optional.of(action == RuleAction.ALLOW_NO_REPORT);
9✔
385
  }
386

387
  /**
388
   * Walk the rule list in order; the first match wins.
389
   *
390
   * @param cacheKey the key to evaluate against all rules
391
   * @return the {@link RuleAction} of the first matching rule, or {@link RuleAction#ALLOW} if no rule matches
392
   */
393
  public RuleAction evaluateRule(String cacheKey) {
394
    for (Rule rule : rulesList) {
11✔
395
      if (rule.match(cacheKey)) {
4✔
396
        return rule.getAction();
3✔
397
      }
398
    }
1✔
399
    return RuleAction.ALLOW;
2✔
400
  }
401

402
  /**
403
   * Serialize the current rule list to the versioned JSON format, write it to Redis
404
   * (if available) via Lua compare-and-set, and broadcast it via AMQP (if available).
405
   * <p>
406
   * Both channels are invoked independently — the XOR pattern has been replaced
407
   * by both/and. Failures are logged but never propagated.
408
   */
409
  private void persistAndBroadcastRules() {
410
    try {
411
      long version = rulesVersion.get();
4✔
412
      String json = serializeRules(new ArrayList<>(rulesList), version);
9✔
413

414
      redisTemplate.ifPresent(r -> persistToRedis(r, json, version));
13✔
415
      cacheSyncPublisher.ifPresent(p -> p.broadcastAllLocalRules(json, version));
11✔
416

417
      log.debug("Rules persisted (version={}), total: {}", version, rulesList.size());
9✔
UNCOV
418
    } catch (Exception e) {
×
UNCOV
419
      log.error("Failed to persist rules", e);
×
420
    }
1✔
421
  }
1✔
422

423
  /**
424
   * Write the versioned JSON rule set to Redis via Lua compare-and-set.
425
   * Falls back to plain SET if the Lua script fails.
426
   */
427
  private void persistToRedis(StringRedisTemplate r, String json, long version) {
428
    try {
429
      r.execute(RULE_CAS_SCRIPT, List.of(REDIS_KEY_RULES), json, String.valueOf(version));
17✔
UNCOV
430
    } catch (Exception e) {
×
UNCOV
431
      log.warn("Lua compare-and-set failed for rules (version={}), fallback to plain set", version, e);
×
432
      try {
UNCOV
433
        r.opsForValue().set(REDIS_KEY_RULES, json);
×
UNCOV
434
      } catch (Exception e2) {
×
UNCOV
435
        log.error("Redis fallback set also failed, skipping persist", e2);
×
UNCOV
436
      }
×
437
    }
1✔
438
  }
1✔
439

440
  /**
441
   * Serialize a rule list and its associated version into the versioned JSON
442
   * wrapper format.
443
   *
444
   * <p>The output format is a JSON object with two top-level keys:
445
   * <pre>{@code {"rulesVersion":5,"rules":[{"pattern":"x","action":"BLOCK"}]}}</pre>
446
   *
447
   * <p>This wrapper format is used for both Redis persistence and AMQP
448
   * broadcast, enabling the receiver to apply version-gated merge logic.
449
   *
450
   * @param rules   the rule list to serialize (a snapshot, typically via
451
   *                {@link #getAllRules()})
452
   * @param version the current rules version to embed in the wrapper
453
   * @return the JSON string representation
454
   * @throws JsonProcessingException if Jackson serialization fails
455
   */
456
  private String serializeRules(List<Rule> rules, long version) throws JsonProcessingException {
457
    Map<String, Object> wrapper = new LinkedHashMap<>();
4✔
458
    wrapper.put("rulesVersion", version);
6✔
459
    wrapper.put("rules", rules);
5✔
460

461
    return OBJECT_MAPPER.writeValueAsString(wrapper);
4✔
462
  }
463

464
  /**
465
   * Merge two rule lists by pattern identity ({@link Rule#getPattern()}).
466
   *
467
   * <p>The merge semantics follow a "last writer wins" strategy on a per-pattern
468
   * basis: for each pattern in the incoming list, the incoming rule replaces
469
   * any local rule with the same pattern. Local rules whose patterns are not
470
   * present in the incoming list are preserved. The order is determined by
471
   * insertion order in the merged map (local rules first, then incoming rules
472
   * overwrite/appending at the end).
473
   *
474
   * <p>This strategy ensures that a rule removal on one node is not
475
   * reintroduced by a stale broadcast from another node: if the incoming set
476
   * does not contain a pattern, the local version is retained.
477
   *
478
   * @param local    the current local rule list
479
   * @param incoming the incoming rule list from a broadcast or Redis load
480
   * @return a new merged rule list combining both sources
481
   */
482
  private static List<Rule> mergeRules(List<Rule> local, List<Rule> incoming) {
483
    Map<String, Rule> map = new LinkedHashMap<>();
4✔
484
    for (Rule rule : local) {
10✔
485
      map.put(rule.getPattern(), rule);
6✔
486
    }
1✔
487
    for (Rule rule : incoming) {
10✔
488
      map.put(rule.getPattern(), rule);
6✔
489
    }
1✔
490
    return new ArrayList<>(map.values());
6✔
491
  }
492

493
  /**
494
   * Parse a JSON string into a {@code List<Rule>}, supporting both the
495
   * legacy array format and the current versioned wrapper format.
496
   *
497
   * <p><b>Legacy format:</b> a plain JSON array of rule objects, e.g.
498
   * <pre>{@code [{"pattern":"x","action":"BLOCK"}]}</pre>
499
   *
500
   * <p><b>Current format:</b> a JSON object with a {@code "rules"} key
501
   * containing the array, e.g.
502
   * <pre>{@code {"rulesVersion":5,"rules":[{"pattern":"x","action":"BLOCK"}]}}</pre>
503
   *
504
   * <p>The format is auto-detected by examining the first character:
505
   * {@code [} indicates legacy array format, {@code \{} indicates the
506
   * versioned wrapper. Backward compatibility is maintained to support
507
   * rolling upgrades.
508
   *
509
   * @param json the JSON string to parse; may be {@code null} or empty
510
   *             (in which case an empty list is returned upstream by the
511
   *             caller's null check)
512
   * @return the parsed list of rules; never {@code null} (returns an
513
   *         empty list if the wrapper format lacks a {@code "rules"} key
514
   *         or if the value is not an array)
515
   * @throws JsonProcessingException if the JSON is malformed
516
   */
517
  private static List<Rule> parseRulesJson(String json) throws JsonProcessingException {
518
    String trimmed = json.trim();
3✔
519
    if (trimmed.startsWith("[")) {
4✔
520
      return OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
11✔
521
    }
522

523
    Map<String, Object> wrapper = OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
11✔
524
    Object raw = wrapper.get("rules");
4✔
525
    if (raw instanceof List<?>) {
3!
526
      return OBJECT_MAPPER.convertValue(raw, new TypeReference<>() {});
11✔
527
    }
UNCOV
528
    return List.of();
×
529
  }
530

531
  /**
532
   * Explicitly reload rules from Redis and broadcast the full rule set to
533
   * all sibling application instances via AMQP.
534
   *
535
   * <p>This method is useful for operational scenarios such as:
536
   * <ul>
537
   *   <li>An operator manually adjusted rules on one node and wants the
538
   *       entire cluster to converge immediately</li>
539
   *   <li>Recovering from a missed broadcast during a temporary network
540
   *       partition</li>
541
   *   <li>Initial cluster synchronization after a new node joins</li>
542
   * </ul>
543
   *
544
   * <p>The rule list is first reloaded from Redis (to pick up any
545
   * externally written changes), then serialized with the current version
546
   * and broadcast. If no {@link CacheSyncPublisher} is configured, the
547
   * method logs a warning and returns without broadcasting.
548
   */
549
  public void broadcastAllLocalRulesManually() {
550
    loadRulesFromRedis();
2✔
551

552
    if (cacheSyncPublisher.isPresent()) {
4✔
553
      try {
554
        long version = rulesVersion.get();
4✔
555
        String json = serializeRules(new ArrayList<>(rulesList), version);
9✔
556
        cacheSyncPublisher.get().broadcastAllLocalRules(json, version);
7✔
557

558
        log.info("Rules broadcast manually (version={}), total: {}", version, rulesList.size());
9✔
UNCOV
559
      } catch (Exception e) {
×
UNCOV
560
        log.error("Failed to serialize rules", e);
×
561
      }
1✔
562
    }
563
  }
1✔
564

565
  /**
566
   * Load persisted rules from Redis (if available) and atomically replace
567
   * the local rule list with the loaded rules.
568
   *
569
   * <p>If Redis is not configured, this method is a no-op. If the Redis
570
   * fetch or JSON parsing fails, the error is logged and the current
571
   * in-memory rule list is preserved — the application continues operating
572
   * with the existing rules.
573
   *
574
   * <p>This method is called at startup via {@link #initRules()} and can
575
   * also be invoked manually via {@link #broadcastAllLocalRulesManually()}
576
   * to refresh from Redis on demand.
577
   */
578
  private void loadRulesFromRedis() {
579
    redisTemplate.ifPresent(r -> {
5✔
580
      try {
581
        String json = r.opsForValue().get(REDIS_KEY_RULES);
6✔
582

583
        if (json == null || json.isEmpty()) {
5✔
584
          log.info("No rules found in Redis, starting with empty rule set");
3✔
585
          return;
1✔
586
        }
587

588
        List<Rule> saved = parseRulesJson(json);
3✔
589
        replaceRules(saved);
3✔
590

591
        log.info("Rules loaded from Redis: {} rules", saved.size());
6✔
592
      } catch (Exception e) {
1✔
593
        log.error("Failed to load rules from Redis", e);
4✔
594
      }
1✔
595
    });
1✔
596
  }
1✔
597
}
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