Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.02% covered (warning)
83.02%
44 / 53
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LockManager
84.62% covered (warning)
84.62%
44 / 52
66.67% covered (warning)
66.67%
6 / 9
24.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
8.14
 lock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lockByType
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 unlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unlockByType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sha1Base36Absolute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizePathsByType
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 doLockByType
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
4.43
 doLock
n/a
0 / 0
n/a
0 / 0
0
 doUnlockByType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 doUnlock
n/a
0 / 0
n/a
0 / 0
0
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\LockManager;
7
8use InvalidArgumentException;
9use Psr\Log\LoggerInterface;
10use Psr\Log\NullLogger;
11use StatusValue;
12use Wikimedia\RequestTimeout\RequestTimeout;
13use Wikimedia\WaitConditionLoop;
14
15/**
16 * @defgroup LockManager Lock management
17 * @ingroup FileBackend
18 */
19
20/**
21 * Resource locking handling.
22 *
23 * Locks on resource keys can either be shared or exclusive.
24 *
25 * Implementations must keep track of what is locked by this process
26 * in-memory and support nested locking calls (using reference counting).
27 * At least LOCK_UW and LOCK_EX must be implemented. LOCK_SH can be a no-op.
28 * Locks should either be non-blocking or have low wait timeouts.
29 *
30 * Subclasses should avoid throwing exceptions at all costs.
31 *
32 * @stable to extend
33 * @ingroup LockManager
34 * @since 1.19
35 */
36abstract class LockManager {
37    /** @var LoggerInterface */
38    protected $logger;
39
40    /** @var array Mapping of lock types to the type actually used */
41    protected $lockTypeMap = [
42        self::LOCK_SH => self::LOCK_SH,
43        self::LOCK_UW => self::LOCK_EX, // subclasses may use self::LOCK_SH
44        self::LOCK_EX => self::LOCK_EX
45    ];
46
47    /** @var array Map of (resource path => lock type => count) */
48    protected $locksHeld = [];
49
50    /** @var string domain (usually wiki ID) */
51    protected $domain;
52    /** @var int maximum time locks can be held */
53    protected $lockTTL;
54
55    /** @var string Random 32-char hex number */
56    protected $session;
57
58    /** Lock types; stronger locks have higher values */
59    public const LOCK_SH = 1; // shared lock (for reads)
60    public const LOCK_UW = 2; // shared lock (for reads used to write elsewhere)
61    public const LOCK_EX = 3; // exclusive lock (for writes)
62
63    /** Max expected lock expiry in any context */
64    protected const MAX_LOCK_TTL = 2 * 3600; // 2 hours
65
66    /** Minimum lock TTL. The configured lockTTL is ignored if it is less than this value. */
67    protected const MIN_LOCK_TTL = 5; // seconds
68
69    /**
70     * Construct a new instance from configuration
71     * @stable to call
72     *
73     * @param array $config Parameters include:
74     *   - domain  : [optional] Domain (usually wiki ID) that all resources are relative to.
75     *   - lockTTL : [optional] Customize the maximum duration (in seconds) that a lock
76     *               may be held for. This TTL is stored by LockManager subclasses and serves
77     *               as a recovery mechanism. If a process crashes before it can unlock, locks
78     *               are eventually released.
79     *               Default for web: 5min, or twice the length of max_execution_time (if higher).
80     *               Default for CLI: 1 hour.
81     */
82    public function __construct( array $config ) {
83        $this->domain = $config['domain'] ?? 'global';
84        if ( isset( $config['lockTTL'] ) ) {
85            $this->lockTTL = max( self::MIN_LOCK_TTL, $config['lockTTL'] );
86        } elseif ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ) {
87            $this->lockTTL = 3600; // 1 hour
88        } else {
89            $ttl = 2 * ceil( RequestTimeout::singleton()->getWallTimeLimit() );
90            $minMaxTTL = 5 * 60; // 5 minutes
91            $this->lockTTL = ( $ttl === INF || $ttl < $minMaxTTL ) ? $minMaxTTL : $ttl;
92        }
93
94        $this->lockTTL = min( $this->lockTTL, self::MAX_LOCK_TTL );
95
96        $random = [];
97        for ( $i = 1; $i <= 5; ++$i ) {
98            $random[] = mt_rand( 0, 0xFFFFFFF );
99        }
100        $this->session = md5( implode( '-', $random ) );
101
102        $this->logger = $config['logger'] ?? new NullLogger();
103    }
104
105    /**
106     * Lock the resources at the given abstract paths
107     *
108     * @param array $paths List of resource names
109     * @param int $type LockManager::LOCK_* constant
110     * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
111     * @return StatusValue
112     */
113    final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) {
114        return $this->lockByType( [ $type => $paths ], $timeout );
115    }
116
117    /**
118     * Lock the resources at the given abstract paths
119     *
120     * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
121     * @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
122     * @return StatusValue
123     * @since 1.22
124     */
125    final public function lockByType( array $pathsByType, $timeout = 0 ) {
126        $pathsByType = $this->normalizePathsByType( $pathsByType );
127
128        $status = null;
129        $loop = new WaitConditionLoop(
130            function () use ( &$status, $pathsByType ) {
131                $status = $this->doLockByType( $pathsByType );
132
133                return $status->isOK() ?: WaitConditionLoop::CONDITION_CONTINUE;
134            },
135            $timeout
136        );
137        $loop->invoke();
138
139        // @phan-suppress-next-line PhanTypeMismatchReturn WaitConditionLoop throws or status is set
140        return $status;
141    }
142
143    /**
144     * Unlock the resources at the given abstract paths
145     *
146     * @param array $paths List of paths
147     * @param int $type LockManager::LOCK_* constant
148     * @return StatusValue
149     */
150    final public function unlock( array $paths, $type = self::LOCK_EX ) {
151        return $this->unlockByType( [ $type => $paths ] );
152    }
153
154    /**
155     * Unlock the resources at the given abstract paths
156     *
157     * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
158     * @return StatusValue
159     * @since 1.22
160     */
161    final public function unlockByType( array $pathsByType ) {
162        $pathsByType = $this->normalizePathsByType( $pathsByType );
163        $status = $this->doUnlockByType( $pathsByType );
164
165        return $status;
166    }
167
168    /**
169     * Get the base 36 SHA-1 of a string, padded to 31 digits.
170     * Before hashing, the path will be prefixed with the domain ID.
171     * This should be used internally for lock key or file names.
172     *
173     * @param string $path
174     * @return string
175     */
176    final protected function sha1Base36Absolute( $path ) {
177        return \Wikimedia\base_convert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 );
178    }
179
180    /**
181     * Normalize the $paths array by converting LOCK_UW locks into the
182     * appropriate type and removing any duplicated paths for each lock type.
183     *
184     * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
185     * @return array
186     * @since 1.22
187     */
188    final protected function normalizePathsByType( array $pathsByType ) {
189        $res = [];
190        foreach ( $pathsByType as $type => $paths ) {
191            foreach ( $paths as $path ) {
192                if ( (string)$path === '' ) {
193                    throw new InvalidArgumentException( __METHOD__ . ": got empty path." );
194                }
195            }
196            $res[$this->lockTypeMap[$type]] = array_unique( $paths );
197        }
198
199        return $res;
200    }
201
202    /**
203     * @see LockManager::lockByType()
204     * @stable to override
205     * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
206     * @return StatusValue
207     * @since 1.22
208     */
209    protected function doLockByType( array $pathsByType ) {
210        $status = StatusValue::newGood();
211        $lockedByType = []; // map of (type => paths)
212        foreach ( $pathsByType as $type => $paths ) {
213            $status->merge( $this->doLock( $paths, $type ) );
214            if ( $status->isOK() ) {
215                $lockedByType[$type] = $paths;
216            } else {
217                // Release the subset of locks that were acquired
218                foreach ( $lockedByType as $lType => $lPaths ) {
219                    $status->merge( $this->doUnlock( $lPaths, $lType ) );
220                }
221                break;
222            }
223        }
224
225        return $status;
226    }
227
228    /**
229     * Lock resources with the given keys and lock type
230     *
231     * @param array $paths List of paths
232     * @param int $type LockManager::LOCK_* constant
233     * @return StatusValue
234     */
235    abstract protected function doLock( array $paths, $type );
236
237    /**
238     * @see LockManager::unlockByType()
239     * @stable to override
240     * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
241     * @return StatusValue
242     * @since 1.22
243     */
244    protected function doUnlockByType( array $pathsByType ) {
245        $status = StatusValue::newGood();
246        foreach ( $pathsByType as $type => $paths ) {
247            $status->merge( $this->doUnlock( $paths, $type ) );
248        }
249
250        return $status;
251    }
252
253    /**
254     * Unlock resources with the given keys and lock type
255     *
256     * @param array $paths List of paths
257     * @param int $type LockManager::LOCK_* constant
258     * @return StatusValue
259     */
260    abstract protected function doUnlock( array $paths, $type );
261}
262/** @deprecated class alias since 1.46 */
263class_alias( LockManager::class, 'LockManager' );