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