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; } /** * Fence the next unit with a fresh owner read, renewing only when due. * * A short request can check dozens of SQL boundaries. Rewriting the same * lease at every boundary forces repeated durable commits without adding * ownership protection: both a read and a CAS can be superseded after they * return. Every boundary still checks the exact owner, and renewal itself * remains conditional on that owner. The interval leaves five sixths of * the TTL available for an in-flight bounded statement. */ public function checkAndRenew(): bool { if ($this->heldValue === null || $this->lockRow()->valueOf($this->optionName()) !== $this->heldValue) { return false; } $timestamp = ABJ_404_Solution_ExactInteger::read(explode(':', $this->heldValue, 2)[0], 1); if ($timestamp === null) { return false; } return abj_clock()->now() - $timestamp < self::RENEW_AFTER_SECONDS || $this->renew(); } /** 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]; $timestamp = ABJ_404_Solution_ExactInteger::read($timestampPart, 1); return $timestamp === null || (abj_clock()->now() - $timestamp) > 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'; } }