AutoPagerizeRuleCatalog.java
package net.sasasin.sreader.service.autopagerize;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import net.sasasin.sreader.domain.AutoPagerizeDataset;
import net.sasasin.sreader.domain.AutoPagerizeRule;
import net.sasasin.sreader.domain.AutoPagerizeRuleCounts;
import net.sasasin.sreader.repository.AutoPagerizeDatasetRepository;
import net.sasasin.sreader.repository.AutoPagerizeRuleRepository;
import net.sasasin.sreader.repository.AutoPagerizeStateRepository;
import org.springframework.stereotype.Service;
/**
* Pull-model immutable rule catalog. Active dataset id is re-checked on each active snapshot
* request; LISTEN/NOTIFY is not used. Holders of an old snapshot may finish work after active
* switch.
*/
@Service
public class AutoPagerizeRuleCatalog {
private final AutoPagerizeStateRepository stateRepository;
private final AutoPagerizeDatasetRepository datasetRepository;
private final AutoPagerizeRuleRepository ruleRepository;
private final AutoPagerizeUrlPatternCompiler urlPatternCompiler;
private final AtomicReference<CachedSnapshot> cache = new AtomicReference<>();
public AutoPagerizeRuleCatalog(
AutoPagerizeStateRepository stateRepository,
AutoPagerizeDatasetRepository datasetRepository,
AutoPagerizeRuleRepository ruleRepository,
AutoPagerizeUrlPatternCompiler urlPatternCompiler) {
this.stateRepository = stateRepository;
this.datasetRepository = datasetRepository;
this.ruleRepository = ruleRepository;
this.urlPatternCompiler = urlPatternCompiler;
}
/**
* @return empty when no dataset is active
*/
public Optional<AutoPagerizeRuleSnapshot> getActiveSnapshot() {
Optional<Long> activeId = stateRepository.findActiveDatasetId();
if (activeId.isEmpty()) {
return Optional.empty();
}
return Optional.of(getOrLoad(activeId.get(), true));
}
/** Loads (or reuses cache for) a specific dataset snapshot. */
public AutoPagerizeRuleSnapshot getSnapshot(long datasetId) {
return getOrLoad(datasetId, false);
}
/** Test helper: clears the in-process cache. */
void clearCache() {
cache.set(null);
}
private AutoPagerizeRuleSnapshot getOrLoad(long datasetId, boolean forActive) {
CachedSnapshot current = cache.get();
if (current != null && current.datasetId() == datasetId) {
return current.snapshot();
}
AutoPagerizeRuleSnapshot loaded = loadAndCompile(datasetId);
if (forActive) {
// Atomic publish: readers still holding the previous snapshot continue safely.
cache.set(new CachedSnapshot(datasetId, loaded));
} else {
// Non-active lookup may still warm the cache when nothing is cached yet.
cache.compareAndSet(null, new CachedSnapshot(datasetId, loaded));
CachedSnapshot after = cache.get();
if (after != null && after.datasetId() == datasetId) {
return after.snapshot();
}
}
return loaded;
}
private AutoPagerizeRuleSnapshot loadAndCompile(long datasetId) {
AutoPagerizeDataset dataset =
datasetRepository
.findById(datasetId)
.orElseThrow(
() ->
new AutoPagerizeCatalogException(
"AutoPagerize dataset not found: " + datasetId));
List<AutoPagerizeRule> rules =
ruleRepository.findRulesByDatasetIdOrderedByMatchOrder(datasetId);
if (rules.size() != dataset.acceptedRuleCount()) {
throw new AutoPagerizeCatalogException(
"Dataset "
+ datasetId
+ " rule count mismatch: stored accepted_rule_count="
+ dataset.acceptedRuleCount()
+ ", loaded="
+ rules.size());
}
AutoPagerizeRuleCounts counts = ruleRepository.countByDatasetId(datasetId);
if (counts.rejectedRuleCount() != dataset.rejectedRuleCount()) {
throw new AutoPagerizeCatalogException(
"Dataset "
+ datasetId
+ " rejection count mismatch: stored rejected_rule_count="
+ dataset.rejectedRuleCount()
+ ", loaded="
+ counts.rejectedRuleCount());
}
List<CompiledAutoPagerizeRule> compiled = new ArrayList<>(rules.size());
for (int expectedMatchOrder = 0; expectedMatchOrder < rules.size(); expectedMatchOrder++) {
AutoPagerizeRule rule = rules.get(expectedMatchOrder);
if (rule.datasetId() != datasetId || rule.matchOrder() != expectedMatchOrder) {
throw new AutoPagerizeCatalogException(
"Dataset "
+ datasetId
+ " match_order mismatch at position "
+ expectedMatchOrder
+ ": loaded dataset_id="
+ rule.datasetId()
+ ", match_order="
+ rule.matchOrder());
}
compiled.add(compile(rule));
}
return new AutoPagerizeRuleSnapshot(
dataset.id(), dataset.sourceSha256(), dataset.importerVersion(), compiled);
}
private CompiledAutoPagerizeRule compile(AutoPagerizeRule rule) {
Objects.requireNonNull(rule, "rule must not be null");
final Pattern pattern;
try {
pattern = urlPatternCompiler.compile(rule.urlPattern());
} catch (PatternSyntaxException e) {
throw new AutoPagerizeCatalogException(
"Internal consistency error: failed to compile stored url_pattern for dataset "
+ rule.datasetId()
+ " ordinal "
+ rule.ordinal()
+ ": "
+ rule.urlPattern(),
e);
}
return new CompiledAutoPagerizeRule(
rule.datasetId(),
rule.ordinal(),
rule.matchOrder(),
rule.name(),
pattern,
rule.urlPattern(),
rule.nextLinkXpath(),
rule.pageElementXpath(),
rule.insertBeforeXpath(),
rule.exampleUrl());
}
private record CachedSnapshot(long datasetId, AutoPagerizeRuleSnapshot snapshot) {}
}