Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.74% covered (success)
90.74%
49 / 54
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependencyStore
90.74% covered (success)
90.74%
49 / 54
71.43% covered (warning)
71.43%
5 / 7
22.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newEntityDependencies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 retrieve
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 retrieveMulti
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 storeMulti
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
11.10
 remove
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 getStoreKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\ResourceLoader;
8
9use InvalidArgumentException;
10use MediaWiki\Deferred\DeferredUpdates;
11use MediaWiki\MediaWikiServices;
12use 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 */
19class 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}