PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / ngram / NGramCoveragePolicy.php

NGramCoveragePolicy.php in 404 Solution 4.3.0, at includes/ngram/NGramCoveragePolicy.php

157 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Owns the N-gram cache coverage state.
9 *
10 * Three responsibilities, all about "is the N-gram cache complete enough
11 * to trust on this request": coverage transient (ratio of ngram entries to
12 * permalink entries with version-based invalidation), multisite-aware
13 * initialization flag, and the invalidation primitive every write path
14 * calls after touching the underlying tables.
15 *
16 * Owns its own count memo because the ratio computation needs both ngram
17 * and permalink counts in lockstep; the repository's own memo is for a
18 * different consumer (findSimilarPages strategy choice) and the duplicated
19 * COUNT(*) is one indexed query per request worst case.
20 */
21 class ABJ_404_Solution_NGramCoveragePolicy {
22
23 /** Cache TTL for coverage ratio transient (seconds). */
24 const COVERAGE_RATIO_CACHE_TTL = 300; // 5 minutes
25
26 /** TTL for coverage version transient (seconds). */
27 const COVERAGE_VERSION_TTL = 86400; // 1 day
28
29 /** Transient key for coverage ratio cache version. */
30 const COVERAGE_VERSION_KEY = 'abj404_ngram_coverage_version';
31
32 /** Transient key for coverage ratio cache data. */
33 const COVERAGE_RATIO_KEY = 'abj404_ngram_coverage_ratio';
34
35 /** @var ABJ_404_Solution_DatabaseCore */
36 private $dbCore;
37
38 /** @var array<string, mixed>|null Per-request memoized coverage ratio data */
39 private $coverageRatioMemo = null;
40
41 /**
42 * @param ABJ_404_Solution_DatabaseCore|null $dbCore
43 */
44 public function __construct($dbCore = null) {
45 $this->dbCore = $dbCore !== null ? $dbCore : abj_service('db_core');
46 }
47
48 /**
49 * Invalidate coverage ratio caches (transient and per-request memos).
50 *
51 * Call this whenever N-gram or permalink counts change, including after
52 * TRUNCATE operations during cache rebuilds.
53 *
54 * Uses timestamp-based versioning: sets version to current time().
55 * Cached ratios with older timestamps are stale. This approach is:
56 * - Overflow-safe: no accumulating counter
57 * - Race-safe: concurrent invalidations both write current time
58 *
59 * @return void
60 */
61 public function invalidateCoverageCaches() {
62 // allow-cache-empty: timestamp marker versions coverage caches after invalidation; not a cached query payload.
63 set_transient(self::COVERAGE_VERSION_KEY, abj_clock()->now(), self::COVERAGE_VERSION_TTL);
64 delete_transient(self::COVERAGE_RATIO_KEY);
65 $this->coverageRatioMemo = null;
66 }
67
68 /**
69 * Check if the N-gram cache is initialized (multisite-aware).
70 *
71 * On multisite, checks both get_site_option() (network activation) and
72 * get_option() (per-site activation) since we can't reliably determine
73 * activation mode on frontend requests where is_plugin_active_for_network()
74 * isn't available.
75 *
76 * @return bool True if cache is initialized
77 */
78 public function isCacheInitialized() {
79 $optionName = 'abj404_ngram_cache_initialized';
80
81 if (is_multisite()) {
82 $siteValue = get_site_option($optionName);
83 if ($siteValue === '1') {
84 return true;
85 }
86 // Fall through to check per-site option
87 }
88
89 return get_option($optionName) === '1';
90 }
91
92 /**
93 * Get cache coverage ratio (ngram entries / permalink entries).
94 *
95 * Used to detect stale or incomplete caches. A ratio < 1.0 indicates
96 * some permalink entries are not in the N-gram cache.
97 *
98 * Memoized per-request and cached in a transient for 5 minutes with
99 * version-based validation to avoid expensive COUNT(*) queries.
100 *
101 * @return float Coverage ratio (0.0 to 1.0+), or 1.0 if permalink cache is empty
102 */
103 public function getCacheCoverageRatio() {
104 if ($this->coverageRatioMemo !== null) {
105 $ratioVal = isset($this->coverageRatioMemo['ratio']) ? $this->coverageRatioMemo['ratio'] : 0;
106 return is_scalar($ratioVal) ? (float)$ratioVal : 0.0;
107 }
108
109 $versionTransient = get_transient(self::COVERAGE_VERSION_KEY);
110 $currentVersion = is_scalar($versionTransient) ? (int)$versionTransient : 0;
111
112 $cached = get_transient(self::COVERAGE_RATIO_KEY);
113 if ($cached !== false && is_array($cached)
114 && isset($cached['ratio'], $cached['version'])
115 && is_scalar($cached['version']) && (int)$cached['version'] === $currentVersion) {
116 // Valid: version matches, trust the cached ratio without COUNT queries
117 /** @var array<string, mixed> $cachedMap */
118 $cachedMap = $cached;
119 $this->coverageRatioMemo = $cachedMap;
120 $ratioOut = isset($cachedMap['ratio']) && is_scalar($cachedMap['ratio']) ? (float)$cachedMap['ratio'] : 0.0;
121 return $ratioOut;
122 }
123
124 // Transient miss or version mismatch - compute fresh ratio
125 $ngramTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache');
126 $permalinkTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_permalink_cache');
127
128 $ngramCount = $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$ngramTable}");
129 $permalinkCount = $this->dbCore->queryScalarInt("SELECT COUNT(*) AS c FROM {$permalinkTable}");
130
131 if ($permalinkCount === 0) {
132 // Empty permalink cache with existing N-grams = stale state (during rebuild)
133 // Return 0.0 to skip prefiltering until both caches are populated
134 $ratio = ($ngramCount === 0) ? 1.0 : 0.0;
135 } else {
136 $ratio = $ngramCount / $permalinkCount;
137 }
138
139 $this->coverageRatioMemo = [
140 'ratio' => $ratio,
141 'ngram_count' => $ngramCount,
142 'permalink_count' => $permalinkCount,
143 'version' => $currentVersion
144 ];
145
146 // @cache-write-audit: opt-out — self-validating cache. The cached
147 // payload carries the coverage version key; any mutation to the
148 // underlying tables calls invalidateCoverageCaches() which bumps
149 // COVERAGE_VERSION_KEY, so a stale entry is invalidated by the next
150 // mutation rather than by an explicit last_error/timed_out check.
151 // Reference fixes: 6315bcb8, c8fba7ee, 2a0a2dd6.
152 set_transient(self::COVERAGE_RATIO_KEY, $this->coverageRatioMemo, self::COVERAGE_RATIO_CACHE_TTL);
153
154 return $ratio;
155 }
156 }
157