Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.74% |
49 / 54 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
| DependencyStore | |
90.74% |
49 / 54 |
|
71.43% |
5 / 7 |
22.38 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| newEntityDependencies | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| retrieve | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| retrieveMulti | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
| storeMulti | |
90.62% |
29 / 32 |
|
0.00% |
0 / 1 |
11.10 | |||
| remove | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
3.58 | |||
| getStoreKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\ResourceLoader; |
| 8 | |
| 9 | use InvalidArgumentException; |
| 10 | use MediaWiki\Deferred\DeferredUpdates; |
| 11 | use MediaWiki\MediaWikiServices; |
| 12 | use Wikimedia\ObjectCache\BagOStuff; |
| 13 | |
| 14 | /** |
| 15 | * Track per-module dependency file paths that are expensive to mass compute |
| 16 | * |
| 17 | * @internal For use by ResourceLoader\Module only |
| 18 | */ |
| 19 | class DependencyStore { |
| 20 | protected const KEY_PATHS = 'paths'; |
| 21 | protected const KEY_AS_OF = 'asOf'; |
| 22 | |
| 23 | /** @var BagOStuff */ |
| 24 | private $stash; |
| 25 | |
| 26 | /** @var int How long to preserve indirect dependency metadata in our backend store. */ |
| 27 | private const RL_MODULE_DEP_TTL = BagOStuff::TTL_YEAR; |
| 28 | |
| 29 | /** @var array Map of (module-variant => buffered DependencyStore updates) */ |
| 30 | public $updateBuffer = []; |
| 31 | |
| 32 | /** |
| 33 | * @param BagOStuff $stash Storage backend |
| 34 | */ |
| 35 | public function __construct( BagOStuff $stash ) { |
| 36 | $this->stash = $stash; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * @param string[] $paths List of dependency paths |
| 41 | * @param int|null $asOf UNIX timestamp or null |
| 42 | * @return array |
| 43 | */ |
| 44 | public function newEntityDependencies( array $paths = [], $asOf = null ) { |
| 45 | return [ self::KEY_PATHS => $paths, self::KEY_AS_OF => $asOf ]; |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * Get the currently tracked dependencies for an entity |
| 50 | * |
| 51 | * The "paths" field contains a sorted list of unique paths |
| 52 | * |
| 53 | * The "asOf" field reflects the last-modified timestamp of the dependency data itself. |
| 54 | * It will be null if there is no tracking data available. Note that if empty path lists |
| 55 | * are never stored (as an optimisation) then it will not be possible to discern whether |
| 56 | * the result is up-to-date. |
| 57 | * |
| 58 | * @param string $entity Entity name |
| 59 | * @return array Map of (paths: paths, asOf: UNIX timestamp or null) |
| 60 | */ |
| 61 | final public function retrieve( $entity ) { |
| 62 | return $this->retrieveMulti( [ $entity ] )[$entity]; |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Get the currently tracked dependencies for a set of entities |
| 67 | * |
| 68 | * @param string[] $entities Entity names |
| 69 | * @return array[] Map of (entity => (paths: paths, asOf: UNIX timestamp or null)) |
| 70 | */ |
| 71 | public function retrieveMulti( array $entities ) { |
| 72 | $entitiesByKey = []; |
| 73 | foreach ( $entities as $entity ) { |
| 74 | $entitiesByKey[$this->getStoreKey( $entity )] = $entity; |
| 75 | } |
| 76 | |
| 77 | $blobsByKey = $this->stash->getMulti( array_keys( $entitiesByKey ) ); |
| 78 | |
| 79 | $results = []; |
| 80 | foreach ( $entitiesByKey as $key => $entity ) { |
| 81 | $blob = $blobsByKey[$key] ?? null; |
| 82 | $data = is_string( $blob ) ? json_decode( $blob, true ) : null; |
| 83 | $results[$entity] = $this->newEntityDependencies( |
| 84 | $data[self::KEY_PATHS] ?? [], |
| 85 | $data[self::KEY_AS_OF] ?? null |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | return $results; |
| 90 | } |
| 91 | |
| 92 | public function storeMulti( array $pathByEntity ): void { |
| 93 | $hasPendingUpdate = (bool)$this->updateBuffer; |
| 94 | |
| 95 | foreach ( $pathByEntity as $entity => $paths ) { |
| 96 | $this->updateBuffer[$entity] = $paths ? $this->newEntityDependencies( $paths, time() ) : null; |
| 97 | } |
| 98 | |
| 99 | // If paths were unchanged, leave the dependency store unchanged also. |
| 100 | // The entry will eventually expire, after which we will briefly issue an incomplete |
| 101 | // version hash for a 5-min startup window, the module then recomputes and rediscovers |
| 102 | // the paths and arrive at the same module version hash once again. It will churn |
| 103 | // part of the browser cache once, for clients connecting during that window. |
| 104 | |
| 105 | if ( !$hasPendingUpdate ) { |
| 106 | DeferredUpdates::addCallableUpdate( function () { |
| 107 | $updatesByEntity = $this->updateBuffer; |
| 108 | $this->updateBuffer = []; |
| 109 | $scopeLocks = []; |
| 110 | $depsByEntity = []; |
| 111 | $entitiesUnreg = []; |
| 112 | $blobsByKey = []; |
| 113 | $cache = MediaWikiServices::getInstance() |
| 114 | ->getObjectCacheFactory()->getLocalClusterInstance(); |
| 115 | foreach ( $updatesByEntity as $entity => $update ) { |
| 116 | $lockKey = $cache->makeKey( 'rl-deps', $entity ); |
| 117 | $scopeLocks[$entity] = $cache->getScopedLock( $lockKey, 0 ); |
| 118 | if ( !$scopeLocks[$entity] ) { |
| 119 | // avoid duplicate write request slams (T124649) |
| 120 | // the lock must be specific to the current wiki (T247028) |
| 121 | continue; |
| 122 | } |
| 123 | if ( !$update ) { |
| 124 | $entitiesUnreg[] = $entity; |
| 125 | } else { |
| 126 | $depsByEntity[$entity] = $update; |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | foreach ( $depsByEntity as $entity => $data ) { |
| 131 | if ( !is_array( $data[self::KEY_PATHS] ) || !is_int( $data[self::KEY_AS_OF] ) ) { |
| 132 | throw new InvalidArgumentException( "Invalid entry for '$entity'" ); |
| 133 | } |
| 134 | |
| 135 | // Normalize the list by removing duplicates and sorting |
| 136 | $data[self::KEY_PATHS] = array_values( array_unique( $data[self::KEY_PATHS] ) ); |
| 137 | sort( $data[self::KEY_PATHS], SORT_STRING ); |
| 138 | |
| 139 | $blob = json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); |
| 140 | |
| 141 | $blobsByKey[$this->getStoreKey( $entity )] = $blob; |
| 142 | } |
| 143 | |
| 144 | if ( $blobsByKey ) { |
| 145 | $this->stash->setMulti( $blobsByKey, self::RL_MODULE_DEP_TTL, BagOStuff::WRITE_BACKGROUND ); |
| 146 | } |
| 147 | |
| 148 | $this->remove( $entitiesUnreg ); |
| 149 | } ); |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * Delete the currently tracked dependencies for an entity or set of entities |
| 155 | * |
| 156 | * @param string|string[] $entities Entity name(s) |
| 157 | */ |
| 158 | public function remove( $entities ) { |
| 159 | $keys = []; |
| 160 | foreach ( (array)$entities as $entity ) { |
| 161 | $keys[] = $this->getStoreKey( $entity ); |
| 162 | } |
| 163 | |
| 164 | if ( $keys ) { |
| 165 | $this->stash->deleteMulti( $keys, BagOStuff::WRITE_BACKGROUND ); |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * @param string $entity |
| 171 | * @return string |
| 172 | */ |
| 173 | private function getStoreKey( $entity ) { |
| 174 | return $this->stash->makeKey( "ResourceLoaderModule-dependencies", $entity ); |
| 175 | } |
| 176 | } |