| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* The persisted attempt counter for one detach A/B workload scope. |
| 9 |
* |
| 10 |
* Split out of ABJ_404_Solution_DetachAbExperiment, which decides WHICH mode an |
| 11 |
* attempt slot receives and had no business also owning where the slot number |
| 12 |
* is stored. The experiment is policy; this is the data access behind it. |
| 13 |
* |
| 14 |
* The store is a transient and the read-modify-write is deliberately not |
| 15 |
* atomic. This is a bounded diagnostic sequence, not a security limit: two |
| 16 |
* tabs racing can land on the same slot, which costs one unusable pair in the |
| 17 |
* evidence, and that is cheaper than coupling diagnostics policy to the |
| 18 |
* database layer. The race is recorded here as a known property rather than |
| 19 |
* left for a reader to infer from the absence of a lock. |
| 20 |
*/ |
| 21 |
final class ABJ_404_Solution_DetachAbAttemptCounter { |
| 22 |
|
| 23 |
/** No slot could be reserved. Callers answer inert rather than guessing one. */ |
| 24 |
const NO_SLOT = -1; |
| 25 |
|
| 26 |
/** How long an idle scope keeps its place in the sequence. */ |
| 27 |
const DEFAULT_TTL_SECONDS = 3600; |
| 28 |
|
| 29 |
/** |
| 30 |
* Reserve and return this scope's next attempt slot. |
| 31 |
* |
| 32 |
* Returns NO_SLOT when the scope cannot carry a counter, when the transient |
| 33 |
* API is unavailable, or when the reservation could not be persisted. |
| 34 |
* |
| 35 |
* That last case used to return the slot anyway. A failed write leaves the |
| 36 |
* stored counter where it was, so every later request in the session read |
| 37 |
* the same value and was handed attempt zero again: the sequence could |
| 38 |
* never reach MAX_ATTEMPTS, never revert to `default`, and the experiment's |
| 39 |
* promise that a probe never permanently degrades an admin session was |
| 40 |
* broken by precisely the infrastructure failure the plugin is built to |
| 41 |
* degrade past. An unreserved slot is not a slot. |
| 42 |
*/ |
| 43 |
public static function reserveNextSlot(ABJ_404_Solution_DetachAbScope $scope): int { |
| 44 |
if ($scope->isSessionless() |
| 45 |
|| !function_exists('get_transient') |
| 46 |
|| !function_exists('set_transient')) { |
| 47 |
return self::NO_SLOT; |
| 48 |
} |
| 49 |
|
| 50 |
$key = $scope->transientKey(); |
| 51 |
$stored = ABJ_404_Solution_DetachAbResolutionTracer::traceTransientOperation( |
| 52 |
'get_transient', |
| 53 |
$key, |
| 54 |
static function () use ($key) { |
| 55 |
return get_transient($key); |
| 56 |
} |
| 57 |
); |
| 58 |
$slot = self::storedSlot($stored); |
| 59 |
|
| 60 |
// allow-cache-empty: locally computed attempt counter (always a valid |
| 61 |
// non-negative int), not a fetched query result. |
| 62 |
$persisted = ABJ_404_Solution_DetachAbResolutionTracer::traceTransientOperation( |
| 63 |
'set_transient', |
| 64 |
$key, |
| 65 |
static function () use ($key, $slot) { |
| 66 |
// allow-cache-empty: the locally computed attempt counter is a |
| 67 |
// valid non-negative integer, never a fetched result. |
| 68 |
return set_transient($key, $slot + 1, self::DEFAULT_TTL_SECONDS); |
| 69 |
} |
| 70 |
); |
| 71 |
|
| 72 |
return $persisted ? $slot : self::NO_SLOT; |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Interpret a stored counter, accepting only a canonical non-negative integer. |
| 77 |
* |
| 78 |
* is_numeric() plus an int cast accepted '2.9' and '1e3' and turned them |
| 79 |
* into 2 and 1000, so a corrupted or foreign value under this key was |
| 80 |
* promoted into a real-looking slot and journalled as A/B coordinates -- |
| 81 |
* fabricated evidence, which is worse for a diagnostic than no evidence. |
| 82 |
* Anything that is not a counter starts the sequence over; the write that |
| 83 |
* follows immediately replaces it with a canonical value, so a garbled |
| 84 |
* store self-heals on the next request instead of resetting forever. |
| 85 |
* |
| 86 |
* ABJ_404_Solution_ExactInteger is that rule, already generalised: its own |
| 87 |
* docblock names this experiment's attempt ordinal as one of the two places |
| 88 |
* that discovered the defect independently. This counter is the third, and |
| 89 |
* hand-rolling a fourth narrow pattern here is how there would be a fifth. |
| 90 |
* |
| 91 |
* @param mixed $stored |
| 92 |
*/ |
| 93 |
private static function storedSlot($stored): int { |
| 94 |
return ABJ_404_Solution_ExactInteger::readOr($stored, 0, 0); |
| 95 |
} |
| 96 |
} |
| 97 |
|