1<?php
2/**
3 * @defgroup LockManager Lock management
4 * @ingroup FileBackend
5 */
6use Psr\Log\LoggerInterface;
7use Psr\Log\NullLogger;
8use Wikimedia\WaitConditionLoop;
9
10/**
11 * Resource locking handling.
12 *
13 * This program is free software; you can redistribute it and/or modify
14 * it under the terms of the GNU General Public License as published by
15 * the Free Software Foundation; either version 2 of the License, or
16 * (at your option) any later version.
17 *
18 * This program is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 * GNU General Public License for more details.
22 *
23 * You should have received a copy of the GNU General Public License along
24 * with this program; if not, write to the Free Software Foundation, Inc.,
25 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26 * http://www.gnu.org/copyleft/gpl.html
27 *
28 * @file
29 * @ingroup LockManager
30 */
31
32/**
33 * @brief Class for handling resource locking.
34 *
35 * Locks on resource keys can either be shared or exclusive.
36 *
37 * Implementations must keep track of what is locked by this process
38 * in-memory and support nested locking calls (using reference counting).
39 * At least LOCK_UW and LOCK_EX must be implemented. LOCK_SH can be a no-op.
40 * Locks should either be non-blocking or have low wait timeouts.
41 *
42 * Subclasses should avoid throwing exceptions at all costs.
43 *
44 * @stable to extend
45 * @ingroup LockManager
46 * @since 1.19
47 */
48abstract class LockManager {
49	/** @var LoggerInterface */
50	protected $logger;
51
52	/** @var array Mapping of lock types to the type actually used */
53	protected $lockTypeMap = [
54		self::LOCK_SH => self::LOCK_SH,
55		self::LOCK_UW => self::LOCK_EX, // subclasses may use self::LOCK_SH
56		self::LOCK_EX => self::LOCK_EX
57	];
58
59	/** @var array Map of (resource path => lock type => count) */
60	protected $locksHeld = [];
61
62	protected $domain; // string; domain (usually wiki ID)
63	protected $lockTTL; // integer; maximum time locks can be held
64
65	/** @var string Random 32-char hex number */
66	protected $session;
67
68	/** Lock types; stronger locks have higher values */
69	public const LOCK_SH = 1; // shared lock (for reads)
70	public const LOCK_UW = 2; // shared lock (for reads used to write elsewhere)
71	public const LOCK_EX = 3; // exclusive lock (for writes)
72
73	/** @var int Max expected lock expiry in any context */
74	protected const MAX_LOCK_TTL = 7200; // 2 hours
75
76	/**
77	 * Construct a new instance from configuration
78	 * @stable to call
79	 *
80	 * @param array $config Parameters include:
81	 *   - domain  : Domain (usually wiki ID) that all resources are relative to [optional]
82	 *   - lockTTL : Age (in seconds) at which resource locks should expire.
83	 *               This only applies if locks are not tied to a connection/process.
84	 */
85	public function __construct( array $config ) {
86		$this->domain = $config['domain'] ?? 'global';
87		if ( isset( $config['lockTTL'] ) ) {
88			$this->lockTTL = max( 5, $config['lockTTL'] );
89		} elseif ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ) {
90			$this->lockTTL = 3600;
91		} else {
92			$met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode
93			$this->lockTTL = max( 5 * 60, 2 * (int)$met );
94		}
95
96		// Upper bound on how long to keep lock structures around. This is useful when setting
97		// TTLs, as the "lockTTL" value may vary based on CLI mode and app server group. This is
98		// a "safe" value that can be used to avoid clobbering other locks that use high TTLs.
99		$this->lockTTL = min( $this->lockTTL, self::MAX_LOCK_TTL );
100
101		$random = [];
102		for ( $i = 1; $i <= 5; ++$i ) {
103			$random[] = mt_rand( 0, 0xFFFFFFF );
104		}
105		$this->session = md5( implode( '-', $random ) );
106
107		$this->logger = $config['logger'] ?? new NullLogger();
108	}
109
110	/**
111	 * Lock the resources at the given abstract paths
112	 *
113	 * @param array $paths List of resource names
114	 * @param int $type LockManager::LOCK_* constant
115	 * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
116	 * @return StatusValue
117	 */
118	final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) {
119		return $this->lockByType( [ $type => $paths ], $timeout );
120	}
121
122	/**
123	 * Lock the resources at the given abstract paths
124	 *
125	 * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
126	 * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
127	 * @return StatusValue
128	 * @since 1.22
129	 */
130	final public function lockByType( array $pathsByType, $timeout = 0 ) {
131		$pathsByType = $this->normalizePathsByType( $pathsByType );
132
133		$status = null;
134		$loop = new WaitConditionLoop(
135			function () use ( &$status, $pathsByType ) {
136				$status = $this->doLockByType( $pathsByType );
137
138				return $status->isOK() ?: WaitConditionLoop::CONDITION_CONTINUE;
139			},
140			$timeout
141		);
142		$loop->invoke();
143
144		return $status;
145	}
146
147	/**
148	 * Unlock the resources at the given abstract paths
149	 *
150	 * @param array $paths List of paths
151	 * @param int $type LockManager::LOCK_* constant
152	 * @return StatusValue
153	 */
154	final public function unlock( array $paths, $type = self::LOCK_EX ) {
155		return $this->unlockByType( [ $type => $paths ] );
156	}
157
158	/**
159	 * Unlock the resources at the given abstract paths
160	 *
161	 * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
162	 * @return StatusValue
163	 * @since 1.22
164	 */
165	final public function unlockByType( array $pathsByType ) {
166		$pathsByType = $this->normalizePathsByType( $pathsByType );
167		$status = $this->doUnlockByType( $pathsByType );
168
169		return $status;
170	}
171
172	/**
173	 * Get the base 36 SHA-1 of a string, padded to 31 digits.
174	 * Before hashing, the path will be prefixed with the domain ID.
175	 * This should be used internally for lock key or file names.
176	 *
177	 * @param string $path
178	 * @return string
179	 */
180	final protected function sha1Base36Absolute( $path ) {
181		return Wikimedia\base_convert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 );
182	}
183
184	/**
185	 * Get the base 16 SHA-1 of a string, padded to 31 digits.
186	 * Before hashing, the path will be prefixed with the domain ID.
187	 * This should be used internally for lock key or file names.
188	 *
189	 * @param string $path
190	 * @return string
191	 */
192	final protected function sha1Base16Absolute( $path ) {
193		return sha1( "{$this->domain}:{$path}" );
194	}
195
196	/**
197	 * Normalize the $paths array by converting LOCK_UW locks into the
198	 * appropriate type and removing any duplicated paths for each lock type.
199	 *
200	 * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
201	 * @return array
202	 * @since 1.22
203	 */
204	final protected function normalizePathsByType( array $pathsByType ) {
205		$res = [];
206		foreach ( $pathsByType as $type => $paths ) {
207			foreach ( $paths as $path ) {
208				if ( (string)$path === '' ) {
209					throw new InvalidArgumentException( __METHOD__ . ": got empty path." );
210				}
211			}
212			$res[$this->lockTypeMap[$type]] = array_unique( $paths );
213		}
214
215		return $res;
216	}
217
218	/**
219	 * @see LockManager::lockByType()
220	 * @stable to override
221	 * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
222	 * @return StatusValue
223	 * @since 1.22
224	 */
225	protected function doLockByType( array $pathsByType ) {
226		$status = StatusValue::newGood();
227		$lockedByType = []; // map of (type => paths)
228		foreach ( $pathsByType as $type => $paths ) {
229			$status->merge( $this->doLock( $paths, $type ) );
230			if ( $status->isOK() ) {
231				$lockedByType[$type] = $paths;
232			} else {
233				// Release the subset of locks that were acquired
234				foreach ( $lockedByType as $lType => $lPaths ) {
235					$status->merge( $this->doUnlock( $lPaths, $lType ) );
236				}
237				break;
238			}
239		}
240
241		return $status;
242	}
243
244	/**
245	 * Lock resources with the given keys and lock type
246	 *
247	 * @param array $paths List of paths
248	 * @param int $type LockManager::LOCK_* constant
249	 * @return StatusValue
250	 */
251	abstract protected function doLock( array $paths, $type );
252
253	/**
254	 * @see LockManager::unlockByType()
255	 * @stable to override
256	 * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
257	 * @return StatusValue
258	 * @since 1.22
259	 */
260	protected function doUnlockByType( array $pathsByType ) {
261		$status = StatusValue::newGood();
262		foreach ( $pathsByType as $type => $paths ) {
263			$status->merge( $this->doUnlock( $paths, $type ) );
264		}
265
266		return $status;
267	}
268
269	/**
270	 * Unlock resources with the given keys and lock type
271	 *
272	 * @param array $paths List of paths
273	 * @param int $type LockManager::LOCK_* constant
274	 * @return StatusValue
275	 */
276	abstract protected function doUnlock( array $paths, $type );
277}
278