PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / logs / LogsHitsRebuildLock.php

LogsHitsRebuildLock.php in 404 Solution trunk, at includes/logs/LogsHitsRebuildLock.php

183 lines 6.6 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 * The cross-request lock that lets only one hits-table rebuild run at a time.
9 *
10 * Rebuilding wp_abj404_logs_hits reads the whole logs table and swaps the
11 * result in. Two of them at once is wasted work at best, and at worst one
12 * rebuild's swap lands under the other's read. Cron, the shutdown listener and
13 * an admin page view can all reach the rebuild in the same second, so the lock
14 * is what keeps that to one.
15 *
16 * Occupancy is decided by ABJ_404_Solution_ExclusiveOptionRow, which claims the
17 * row with an INSERT that UNIQUE(option_name) satisfies exactly once. This used
18 * to be add_option(), on the understanding that it returns false for a name
19 * that already exists; WordPress guards add_option() with a cache-served
20 * get_option() and, since 6.4, writes with INSERT ... ON DUPLICATE KEY UPDATE,
21 * so two concurrent callers could both be told they had added it.
22 *
23 * What lives HERE rather than in that primitive is the policy: how long a
24 * holder may hold, when one may be displaced, and what an unreadable value
25 * means. Those differ per lock (the synchronizer breaks on age and releases by
26 * owner id; this one expires on a TTL), which is why the primitive deliberately
27 * knows none of them.
28 */
29 class ABJ_404_Solution_LogsHitsRebuildLock {
30
31 /** @var string|null Exact row value acquired by this instance. */
32 private $heldValue;
33
34 /** How long a holder may hold before a later request may displace it.
35 *
36 * This is the leak bound, not a budget: nothing expires an option row, so
37 * a rebuild killed mid-flight (a fatal, a reaped cron worker) would hold
38 * the lock forever without it. It must comfortably exceed a healthy
39 * rebuild, or a live rebuild gets displaced by the next request and both
40 * then run, which is the thing the lock exists to prevent.
41 *
42 * @var int
43 */
44 const TTL_SECONDS = 180;
45
46 /** @var ABJ_404_Solution_DatabaseCoreInterface */
47 private $dbCore;
48
49 /** @param ABJ_404_Solution_DatabaseCoreInterface $dbCore */
50 public function __construct($dbCore) {
51 $this->dbCore = $dbCore;
52 }
53
54 /** Take the lock, so exactly one rebuild runs.
55 *
56 * The claim comes FIRST, before anything is read. Reading the row and then
57 * deciding whether to write it is the protocol two concurrent requests both
58 * pass, because WordPress answers that read from a per-request cache. The
59 * read below happens only after a claim has already been attempted and
60 * lost, purely to decide whether the holder it lost to has aged out.
61 *
62 * @return bool true only if this request holds the lock.
63 */
64 public function acquire(): bool {
65 $lockName = $this->optionName();
66 $lockRow = $this->lockRow();
67 $now = abj_clock()->now();
68 $claimValue = ABJ_404_Solution_ExclusiveOptionRow::uniqueClaimValue((string)$now);
69 if ($lockRow->claim(array('optionName' => $lockName, 'value' => $claimValue))) {
70 $this->heldValue = $claimValue;
71 return true;
72 }
73
74 // A row already exists. Only a genuinely expired holder may be
75 // displaced, and the retry is another atomic claim, so at most one of
76 // several requests that all found the same expired lock takes it.
77 if (!$this->releaseStaleHolder($lockRow, $lockName)) {
78 return false;
79 }
80
81 $claimValue = ABJ_404_Solution_ExclusiveOptionRow::uniqueClaimValue((string)$now);
82 $claimed = $lockRow->claim(array('optionName' => $lockName, 'value' => $claimValue));
83 if ($claimed) {
84 $this->heldValue = $claimValue;
85 }
86 return $claimed;
87 }
88
89 /** @return void */
90 public function release(): void {
91 if ($this->heldValue === null) {
92 return;
93 }
94 $this->lockRow()->releaseIfValueIs(array(
95 'optionName' => $this->optionName(),
96 'value' => $this->heldValue,
97 ));
98 $this->heldValue = null;
99 }
100
101 /** Refresh the lease only while this instance still owns the row. */
102 public function renew(): bool {
103 if ($this->heldValue === null) {
104 return false;
105 }
106 $replacementValue = ABJ_404_Solution_ExclusiveOptionRow::uniqueClaimValue(
107 (string)abj_clock()->now()
108 );
109 $renewed = $this->lockRow()->replaceValueIfMatches(array(
110 'optionName' => $this->optionName(),
111 'currentValue' => $this->heldValue,
112 'replacementValue' => $replacementValue,
113 ));
114 if ($renewed) {
115 $this->heldValue = $replacementValue;
116 }
117 return $renewed;
118 }
119
120 /** Whether a live (non-expired) holder currently has the lock.
121 * This observation is deliberately side-effect free; stale-row cleanup is
122 * part of acquire(), the operation that needs to replace such a row.
123 *
124 * @return bool
125 */
126 public function isHeld(): bool {
127 $lockName = $this->optionName();
128 $lockRow = $this->lockRow();
129 $lockValue = $lockRow->valueOf($lockName);
130 if ($lockValue === '') {
131 return false;
132 }
133
134 return !$this->isStaleValue($lockValue);
135 }
136
137 /**
138 * Remove an unusable or expired holder after an acquisition attempt lost.
139 * Conditional deletion preserves a newer holder that raced with this read.
140 */
141 private function releaseStaleHolder(
142 ABJ_404_Solution_ExclusiveOptionRow $lockRow,
143 string $lockName
144 ): bool {
145 $lockValue = $lockRow->valueOf($lockName);
146 if ($lockValue === '') {
147 return true;
148 }
149
150 if (!$this->isStaleValue($lockValue)) {
151 return false;
152 }
153
154 return $lockRow->releaseIfValueIs(array(
155 'optionName' => $lockName,
156 'value' => $lockValue,
157 ));
158 }
159
160 /** One liveness rule shared by observation and stale-holder eviction. */
161 private function isStaleValue(string $lockValue): bool {
162 $timestampPart = explode(':', $lockValue, 2)[0];
163 return !is_numeric($timestampPart)
164 || (int)$timestampPart <= 0
165 || (abj_clock()->now() - (int)$timestampPart) > self::TTL_SECONDS;
166 }
167
168 /** The option row that holds the lock. Stateless, so a fresh instance
169 * costs nothing.
170 *
171 * @return ABJ_404_Solution_ExclusiveOptionRow
172 */
173 private function lockRow(): ABJ_404_Solution_ExclusiveOptionRow {
174 return new ABJ_404_Solution_ExclusiveOptionRow();
175 }
176
177 /** @return string */
178 private function optionName(): string {
179 return $this->dbCore->tableNameResolver()->getLowercasePrefix()
180 . 'abj404_logs_hits_rebuild_lock';
181 }
182 }
183