dbCore = $dbCore; } /** Take the lock, so exactly one rebuild runs. * * The claim comes FIRST, before anything is read. Reading the row and then * deciding whether to write it is the protocol two concurrent requests both * pass, because WordPress answers that read from a per-request cache. The * read below happens only after a claim has already been attempted and * lost, purely to decide whether the holder it lost to has aged out. * * @return bool true only if this request holds the lock. */ public function acquire(): bool { $lockName = $this->optionName(); $lockRow = $this->lockRow(); $now = abj_clock()->now(); $claimValue = ABJ_404_Solution_ExclusiveOptionRow::uniqueClaimValue((string)$now); if ($lockRow->claim(array('optionName' => $lockName, 'value' => $claimValue))) { $this->heldValue = $claimValue; return true; } // A row already exists. Only a genuinely expired holder may be // displaced, and the retry is another atomic claim, so at most one of // several requests that all found the same expired lock takes it. if (!$this->releaseStaleHolder($lockRow, $lockName)) { return false; } $claimValue = ABJ_404_Solution_ExclusiveOptionRow::uniqueClaimValue((string)$now); $claimed = $lockRow->claim(array('optionName' => $lockName, 'value' => $claimValue)); if ($claimed) { $this->heldValue = $claimValue; } return $claimed; } /** @return void */ public function release(): void { if ($this->heldValue === null) { return; } $this->lockRow()->releaseIfValueIs(array( 'optionName' => $this->optionName(), 'value' => $this->heldValue, )); $this->heldValue = null; } /** Refresh the lease only while this instance still owns the row. */ public function renew(): bool { if ($this->heldValue === null) { return false; } $replacementValue = ABJ_404_Solution_ExclusiveOptionRow::uniqueClaimValue( (string)abj_clock()->now() ); $renewed = $this->lockRow()->replaceValueIfMatches(array( 'optionName' => $this->optionName(), 'currentValue' => $this->heldValue, 'replacementValue' => $replacementValue, )); if ($renewed) { $this->heldValue = $replacementValue; } return $renewed; } /** Whether a live (non-expired) holder currently has the lock. * This observation is deliberately side-effect free; stale-row cleanup is * part of acquire(), the operation that needs to replace such a row. * * @return bool */ public function isHeld(): bool { $lockName = $this->optionName(); $lockRow = $this->lockRow(); $lockValue = $lockRow->valueOf($lockName); if ($lockValue === '') { return false; } return !$this->isStaleValue($lockValue); } /** * Remove an unusable or expired holder after an acquisition attempt lost. * Conditional deletion preserves a newer holder that raced with this read. */ private function releaseStaleHolder( ABJ_404_Solution_ExclusiveOptionRow $lockRow, string $lockName ): bool { $lockValue = $lockRow->valueOf($lockName); if ($lockValue === '') { return true; } if (!$this->isStaleValue($lockValue)) { return false; } return $lockRow->releaseIfValueIs(array( 'optionName' => $lockName, 'value' => $lockValue, )); } /** One liveness rule shared by observation and stale-holder eviction. */ private function isStaleValue(string $lockValue): bool { $timestampPart = explode(':', $lockValue, 2)[0]; return !is_numeric($timestampPart) || (int)$timestampPart <= 0 || (abj_clock()->now() - (int)$timestampPart) > self::TTL_SECONDS; } /** The option row that holds the lock. Stateless, so a fresh instance * costs nothing. * * @return ABJ_404_Solution_ExclusiveOptionRow */ private function lockRow(): ABJ_404_Solution_ExclusiveOptionRow { return new ABJ_404_Solution_ExclusiveOptionRow(); } /** @return string */ private function optionName(): string { return $this->dbCore->tableNameResolver()->getLowercasePrefix() . 'abj404_logs_hits_rebuild_lock'; } }