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

Hyshmily / hotkey / 28637103998

03 Jul 2026 03:48AM UTC coverage: 90.399% (-0.2%) from 90.63%
28637103998

push

github

Hyshmily
fix: add packaging to gitignore, fix flaky jitter test, update docs references

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

1264 of 1461 branches covered (86.52%)

Branch coverage included in aggregate %.

3604 of 3924 relevant lines covered (91.85%)

4.19 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

201
    if (log.isInfoEnabled()) {
3!
202
      log.info(
8✔
203
        "Rule added: pattern='{}', type={}, action={} (total: {}, version: {})",
204
        rule.getPattern(),
5✔
205
        rule.getType(),
5✔
206
        rule.getAction(),
6✔
207
        rulesList.size(),
7✔
208
        rulesVersion.get()
3✔
209
      );
210
    }
211
  }
1✔
212

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

248
  /**
249
   * Remove all rules and propagate the empty rule list.
250
   *
251
   * <p>After clearing, the rule list is empty, the version counter is
252
   * incremented, and the empty list is persisted to Redis and broadcast
253
   * to sibling instances. This ensures that all nodes converge to the
254
   * empty state.
255
   */
256
  public void clearRules() {
257
    int before = rulesList.size();
4✔
258
    rulesList.clear();
3✔
259
    rulesVersion.incrementAndGet();
4✔
260

261
    persistAndBroadcastRules();
2✔
262
    log.info("All rules cleared (removed: {}, version: {})", before, rulesVersion.get());
9✔
263
  }
1✔
264

265
  /**
266
   * Remove all rules whose action equals the given value.
267
   *
268
   * <p>If any rules are removed, the change is persisted and broadcast.
269
   * The version counter is incremented with each batch removal.
270
   *
271
   * @param action the action to match; all rules with this action are
272
   *               removed regardless of their pattern
273
   * @return the number of rules removed (zero if no matching rules exist)
274
   */
275
  public int removeRulesByAction(RuleAction action) {
276
    int before = rulesList.size();
4✔
277
    rulesList.removeIf(r -> r.getAction() == action);
14✔
278
    int removed = before - rulesList.size();
6✔
279

280
    if (removed > 0) {
2✔
281
      rulesVersion.incrementAndGet();
4✔
282
      persistAndBroadcastRules();
2✔
283
      log.info(
12✔
284
        "Rules removed by action: {} (removed: {}, total: {}, version: {})",
285
        action,
286
        removed,
6✔
287
        rulesList.size(),
7✔
288
        rulesVersion.get()
3✔
289
      );
290
    }
291
    return removed;
2✔
292
  }
293

294
  /**
295
   * Remove the rule at the given index in the current rule list.
296
   *
297
   * <p>If the index is valid (non-negative and less than the current list
298
   * size), the rule is removed and the change is persisted and broadcast.
299
   * Out-of-bounds indices are silently ignored (logged at DEBUG level
300
   * by the caller).
301
   *
302
   * @param index the zero-based index of the rule to remove
303
   */
304
  public void removeRule(int index) {
305
    if (index >= 0 && index < rulesList.size()) {
7✔
306
      Rule removed = rulesList.remove(index);
6✔
307
      rulesVersion.incrementAndGet();
4✔
308
      persistAndBroadcastRules();
2✔
309
      log.info(
8✔
310
        "Rule removed by index {}: pattern='{}', action={} (total: {}, version: {})",
311
        index,
5✔
312
        removed.getPattern(),
5✔
313
        removed.getAction(),
6✔
314
        rulesList.size(),
7✔
315
        rulesVersion.get()
3✔
316
      );
317
    }
318
  }
1✔
319

320
  /**
321
   * Merge the incoming rule set into the local rule set, guarded by version.
322
   * <p>
323
   * If {@code incomingVersion > 0} and {@code <= localVersion}, the sync is skipped
324
   * (stale broadcast). Otherwise the rules are merged by pattern (incoming overwrites
325
   * same-pattern entries, local-only entries are preserved), the local version is
326
   * bumped past the incoming version, and the result is persisted to Redis.
327
   * <p>
328
   * This method <b>does not broadcast</b> — only updates local list and Redis,
329
   * avoiding a broadcast storm.
330
   *
331
   * @param json            the JSON-serialized rule list (may be old-format array or
332
   *                        new-format wrapper)
333
   * @param incomingVersion the rulesVersion from the incoming message header,
334
   *                        or {@code 0L} for pre-version broadcasts
335
   */
336
  public void syncRules(String json, long incomingVersion) {
337
    if (incomingVersion > 0L && incomingVersion <= rulesVersion.get()) {
10!
338
      log.debug("Stale rules sync ignored: incomingVersion={}, localVersion={}", incomingVersion, rulesVersion.get());
×
339
      return;
×
340
    }
341
    try {
342
      List<Rule> incomingRules = parseRulesJson(json);
3✔
343
      List<Rule> merged = mergeRules(rulesList, incomingRules);
5✔
344

345
      replaceRules(merged);
3✔
346

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

349
      String outJson = serializeRules(merged, newVersion);
5✔
350
      redisTemplate.ifPresent(r -> persistToRedis(r, outJson, newVersion));
13✔
351
      log.info("Rules synced from broadcast, version={}, total: {}", newVersion, merged.size());
8✔
352
    } catch (Exception e) {
1✔
353
      log.error("Failed to sync rules from broadcast", e);
4✔
354
    }
1✔
355
  }
1✔
356

357
  /**
358
   * Atomically swap the rule list.  Does <b>not</b> persist or broadcast.
359
   * <p>
360
   * Primarily used internally by {@link #syncRules} and for tests.
361
   *
362
   * @param newRules the new rule list; each element is {@link Rule#prepare() prepared} before
363
   *     insertion, must not be {@code null}
364
   */
365
  public void replaceRules(List<Rule> newRules) {
366
    int oldSize = rulesList.size();
4✔
367
    List<Rule> replacement = new CopyOnWriteArrayList<>();
4✔
368
    newRules.forEach(r -> {
4✔
369
      r.prepare();
2✔
370
      replacement.add(r);
4✔
371
    });
1✔
372
    rulesList = replacement;
3✔
373
    log.info("Rules replaced: {} -> {} rules", oldSize, replacement.size());
8✔
374
  }
1✔
375

376
  /** @return a snapshot of the current rules (safe for iteration). */
377
  public List<Rule> getAllRules() {
378
    return new ArrayList<>(rulesList);
6✔
379
  }
380

381
  /**
382
   * Evaluate the rule set for the given key and return an {@link Optional}
383
   * that describes how the caller should behave.
384
   * <ul>
385
   *   <li>{@code Optional.empty()} – {@code BLOCK}: reject the access immediately.</li>
386
   *   <li>{@code Optional.of(true)} – {@code ALLOW_NO_REPORT}: allow the access
387
   *       but suppress the hot‑key report.</li>
388
   *   <li>{@code Optional.of(false)} – no matching rule or {@code ALLOW}:
389
   *       proceed normally with reporting.</li>
390
   * </ul>
391
   *
392
   * @param cacheKey  the key being accessed
393
   * @param operation a label used only in log messages (e.g. "get", "getWithSoftExpire")
394
   * @return {@link Optional#empty()} if the rule action is {@link RuleAction#BLOCK},
395
   *         {@code Optional.of(true)} if the rule action is {@link RuleAction#ALLOW_NO_REPORT},
396
   *         {@code Optional.of(false)} if no rule matches or the action is {@link RuleAction#ALLOW}
397
   */
398
  public Optional<Boolean> isAllowNoReport(String cacheKey, String operation) {
399
    RuleAction action = evaluateRule(cacheKey);
4✔
400
    if (action == RuleAction.BLOCK) {
3✔
401
      log.debug("{}: blocked by rule: {}", operation, cacheKey);
5✔
402
      return Optional.empty();
2✔
403
    }
404
    return Optional.of(action == RuleAction.ALLOW_NO_REPORT);
9✔
405
  }
406

407
  /**
408
   * Walk the rule list in order; the first match wins.
409
   *
410
   * @param cacheKey the key to evaluate against all rules
411
   * @return the {@link RuleAction} of the first matching rule, or {@link RuleAction#ALLOW} if no rule matches
412
   */
413
  public RuleAction evaluateRule(String cacheKey) {
414
    for (Rule rule : rulesList) {
11✔
415
      if (rule.match(cacheKey)) {
4✔
416
        return rule.getAction();
3✔
417
      }
418
    }
1✔
419
    return RuleAction.ALLOW;
2✔
420
  }
421

422
  /**
423
   * Serialize the current rule list to the versioned JSON format, write it to Redis
424
   * (if available) via Lua compare-and-set, and broadcast it via AMQP (if available).
425
   * <p>
426
   * Both channels are invoked independently — the XOR pattern has been replaced
427
   * by both/and. Failures are logged but never propagated.
428
   */
429
  private void persistAndBroadcastRules() {
430
    try {
431
      long version = rulesVersion.get();
4✔
432
      String json = serializeRules(new ArrayList<>(rulesList), version);
9✔
433

434
      redisTemplate.ifPresent(r -> persistToRedis(r, json, version));
13✔
435
      cacheSyncPublisher.ifPresent(p -> p.broadcastAllLocalRules(json, version));
11✔
436

437
      log.debug("Rules persisted (version={}), total: {}", version, rulesList.size());
9✔
438
    } catch (Exception e) {
×
439
      log.error("Failed to persist rules", e);
×
440
    }
1✔
441
  }
1✔
442

443
  /**
444
   * Write the versioned JSON rule set to Redis via Lua compare-and-set.
445
   * Falls back to plain SET if the Lua script fails.
446
   */
447
  private void persistToRedis(StringRedisTemplate r, String json, long version) {
448
    try {
449
      r.execute(RULE_CAS_SCRIPT, List.of(REDIS_KEY_RULES), json, String.valueOf(version));
17✔
450
    } catch (Exception e) {
×
451
      log.warn("Lua compare-and-set failed for rules (version={}), fallback to plain set", version, e);
×
452
      try {
453
        r.opsForValue().set(REDIS_KEY_RULES, json);
×
454
      } catch (Exception e2) {
×
455
        log.error("Redis fallback set also failed, skipping persist", e2);
×
456
      }
×
457
    }
1✔
458
  }
1✔
459

460
  /**
461
   * Serialize a rule list and its associated version into the versioned JSON
462
   * wrapper format.
463
   *
464
   * <p>The output format is a JSON object with two top-level keys:
465
   * <pre>{@code {"rulesVersion":5,"rules":[{"pattern":"x","action":"BLOCK"}]}}</pre>
466
   *
467
   * <p>This wrapper format is used for both Redis persistence and AMQP
468
   * broadcast, enabling the receiver to apply version-gated merge logic.
469
   *
470
   * @param rules   the rule list to serialize (a snapshot, typically via
471
   *                {@link #getAllRules()})
472
   * @param version the current rules version to embed in the wrapper
473
   * @return the JSON string representation
474
   * @throws JsonProcessingException if Jackson serialization fails
475
   */
476
  private String serializeRules(List<Rule> rules, long version) throws JsonProcessingException {
477
    Map<String, Object> wrapper = new LinkedHashMap<>();
4✔
478
    wrapper.put("rulesVersion", version);
6✔
479
    wrapper.put("rules", rules);
5✔
480

481
    return OBJECT_MAPPER.writeValueAsString(wrapper);
4✔
482
  }
483

484
  /**
485
   * Merge two rule lists by pattern identity ({@link Rule#getPattern()}).
486
   *
487
   * <p>The merge semantics follow a "last writer wins" strategy on a per-pattern
488
   * basis: for each pattern in the incoming list, the incoming rule replaces
489
   * any local rule with the same pattern. Local rules whose patterns are not
490
   * present in the incoming list are preserved. The order is determined by
491
   * insertion order in the merged map (local rules first, then incoming rules
492
   * overwrite/appending at the end).
493
   *
494
   * <p>This strategy ensures that a rule removal on one node is not
495
   * reintroduced by a stale broadcast from another node: if the incoming set
496
   * does not contain a pattern, the local version is retained.
497
   *
498
   * @param local    the current local rule list
499
   * @param incoming the incoming rule list from a broadcast or Redis load
500
   * @return a new merged rule list combining both sources
501
   */
502
  private static List<Rule> mergeRules(List<Rule> local, List<Rule> incoming) {
503
    Map<String, Rule> map = new LinkedHashMap<>();
4✔
504
    for (Rule rule : local) {
10✔
505
      map.put(rule.getPattern(), rule);
6✔
506
    }
1✔
507
    for (Rule rule : incoming) {
10✔
508
      map.put(rule.getPattern(), rule);
6✔
509
    }
1✔
510
    return new ArrayList<>(map.values());
6✔
511
  }
512

513
  /**
514
   * Parse a JSON string into a {@code List<Rule>}, supporting both the
515
   * legacy array format and the current versioned wrapper format.
516
   *
517
   * <p><b>Legacy format:</b> a plain JSON array of rule objects, e.g.
518
   * <pre>{@code [{"pattern":"x","action":"BLOCK"}]}</pre>
519
   *
520
   * <p><b>Current format:</b> a JSON object with a {@code "rules"} key
521
   * containing the array, e.g.
522
   * <pre>{@code {"rulesVersion":5,"rules":[{"pattern":"x","action":"BLOCK"}]}}</pre>
523
   *
524
   * <p>The format is auto-detected by examining the first character:
525
   * {@code [} indicates legacy array format, {@code \{} indicates the
526
   * versioned wrapper. Backward compatibility is maintained to support
527
   * rolling upgrades.
528
   *
529
   * @param json the JSON string to parse; may be {@code null} or empty
530
   *             (in which case an empty list is returned upstream by the
531
   *             caller's null check)
532
   * @return the parsed list of rules; never {@code null} (returns an
533
   *         empty list if the wrapper format lacks a {@code "rules"} key
534
   *         or if the value is not an array)
535
   * @throws JsonProcessingException if the JSON is malformed
536
   */
537
  private static List<Rule> parseRulesJson(String json) throws JsonProcessingException {
538
    String trimmed = json.trim();
3✔
539
    if (trimmed.startsWith("[")) {
4✔
540
      return OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
11✔
541
    }
542

543
    Map<String, Object> wrapper = OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
11✔
544
    Object raw = wrapper.get("rules");
4✔
545
    if (raw instanceof List<?>) {
3!
546
      return OBJECT_MAPPER.convertValue(raw, new TypeReference<>() {});
11✔
547
    }
548
    return List.of();
×
549
  }
550

551
  /**
552
   * Explicitly reload rules from Redis and broadcast the full rule set to
553
   * all sibling application instances via AMQP.
554
   *
555
   * <p>This method is useful for operational scenarios such as:
556
   * <ul>
557
   *   <li>An operator manually adjusted rules on one node and wants the
558
   *       entire cluster to converge immediately</li>
559
   *   <li>Recovering from a missed broadcast during a temporary network
560
   *       partition</li>
561
   *   <li>Initial cluster synchronization after a new node joins</li>
562
   * </ul>
563
   *
564
   * <p>The rule list is first reloaded from Redis (to pick up any
565
   * externally written changes), then serialized with the current version
566
   * and broadcast. If no {@link CacheSyncPublisher} is configured, the
567
   * method logs a warning and returns without broadcasting.
568
   */
569
  public void broadcastAllLocalRulesManually() {
570
    loadRulesFromRedis();
2✔
571

572
    if (cacheSyncPublisher.isPresent()) {
4✔
573
      try {
574
        long version = rulesVersion.get();
4✔
575
        String json = serializeRules(new ArrayList<>(rulesList), version);
9✔
576
        cacheSyncPublisher.get().broadcastAllLocalRules(json, version);
7✔
577

578
        log.info("Rules broadcast manually (version={}), total: {}", version, rulesList.size());
9✔
579
      } catch (Exception e) {
×
580
        log.error("Failed to serialize rules", e);
×
581
      }
1✔
582
    }
583
  }
1✔
584

585
  /**
586
   * Load persisted rules from Redis (if available) and atomically replace
587
   * the local rule list with the loaded rules.
588
   *
589
   * <p>If Redis is not configured, this method is a no-op. If the Redis
590
   * fetch or JSON parsing fails, the error is logged and the current
591
   * in-memory rule list is preserved — the application continues operating
592
   * with the existing rules.
593
   *
594
   * <p>This method is called at startup via {@link #initRules()} and can
595
   * also be invoked manually via {@link #broadcastAllLocalRulesManually()}
596
   * to refresh from Redis on demand.
597
   */
598
  private void loadRulesFromRedis() {
599
    redisTemplate.ifPresent(r -> {
5✔
600
      try {
601
        String json = r.opsForValue().get(REDIS_KEY_RULES);
6✔
602

603
        if (json == null || json.isEmpty()) {
5✔
604
          log.info("No rules found in Redis, starting with empty rule set");
3✔
605
          return;
1✔
606
        }
607

608
        List<Rule> saved = parseRulesJson(json);
3✔
609
        replaceRules(saved);
3✔
610

611
        log.info("Rules loaded from Redis: {} rules", saved.size());
6✔
612
      } catch (Exception e) {
1✔
613
        log.error("Failed to load rules from Redis", e);
4✔
614
      }
1✔
615
    });
1✔
616
  }
1✔
617
}
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