Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.88% covered (warning)
69.88%
58 / 83
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
LCStoreStaticArray
70.73% covered (warning)
70.73%
58 / 82
33.33% covered (danger)
33.33%
4 / 12
92.54
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
 startWrite
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 set
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 isValueArray
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 encode
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
7.14
 decode
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
7.73
 finishWrite
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 atomicWrite
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getMessageKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
9.54
 loadLanguage
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 lateFallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Language;
8
9use RuntimeException;
10use Wikimedia\StaticArrayWriter;
11
12/**
13 * Localisation cache storage based on PHP files and static arrays.
14 * This is meant to leverage the PHP opcache and may require increasing
15 * memory sizes on a production site that handles many languages routinely.
16 *
17 * These `php.ini` settings are sufficient for some local testing, but
18 * would be rather too small for Wikimedia production which has many
19 * localised extensions installed.
20 *
21 * - `opcache.memory_consumption=1024`
22 * - `opcache.interned_strings_buffer=256`
23 *
24 *
25 * As of MediaWiki 1.46, this cache backend does two important things
26 * differently from the CDB backend:
27 *
28 * - Late fallback: tells LocalisationCache to do late fallback/merge
29 *   logic at read time, reducing redundant data in the `.l10n.php` files
30 *   for a slight, hard to measure runtime cost in looking up a few more
31 *   arrays.
32 *
33 *   This gives a massive reduction in both disk usage and opcode cache
34 *   usage versus pre-merging all the fallbacks: strings are already
35 *   deduplicated in the opcache's string interning but const array
36 *   hashmap data is not!
37 *
38 * - Re-assembly of split keys: the `messages` array is reconstructed
39 *   to avoid duplicating the prefix 'messages:' thousands of times and
40 *   make the key list cheap to load.
41 *
42 * Additionally, LCStoreStaticArray silently drops the 'preload' and
43 * 'preloadMessages' data duplication in each language, as it's cheap
44 * to query the messages individually.
45 *
46 * The resulting cache files are about ~80% smaller than under 1.45
47 * due to the drop in redundant data, and compress very well for delta
48 * copies.
49 *
50 * Older cached data from before 1.46 is backwards-compatible on read.
51 *
52 * @since 1.26
53 * @ingroup Language
54 */
55class LCStoreStaticArray implements LCStore {
56
57    /** Individual message subkeys have this prefix in the raw queries */
58    private const MESSAGES_PREFIX = 'messages:';
59
60    /** @var string|null Current language code. */
61    private $currentLang = null;
62
63    /** @var array Localisation data. */
64    private $data = [];
65
66    /** @var string|null File name. */
67    private $fname = null;
68
69    /** @var string Directory for cache files. */
70    private $directory;
71
72    public function __construct( array $conf = [] ) {
73        $this->directory = $conf['directory'];
74    }
75
76    /** @inheritDoc */
77    public function startWrite( $code ) {
78        if ( !is_dir( $this->directory ) && !wfMkdirParents( $this->directory, null, __METHOD__ ) ) {
79            throw new RuntimeException( "Unable to create the localisation store " .
80                "directory \"{$this->directory}\"" );
81        }
82        $this->currentLang = $code;
83        $this->fname = $this->directory . '/' . $code . '.l10n.php';
84        $this->data[$code] = [];
85        if ( is_file( $this->fname ) ) {
86            $this->data[$code] = require $this->fname;
87        }
88    }
89
90    /** @inheritDoc */
91    public function set( $key, $value ) {
92        if ( $key === 'list' ) {
93            // We will recreate this on read from the actual keys
94            unset( $value['messages'] );
95        }
96        if ( $key === 'preload' || $key === 'preloadedMessages' ) {
97            // We don't need this, it's cheap to query the cache.
98            $value = [];
99        }
100        $encoded = self::encode( $value );
101        $data =& $this->data[$this->currentLang];
102        if ( str_starts_with( $key, self::MESSAGES_PREFIX ) ) {
103            $message = substr( $key, strlen( self::MESSAGES_PREFIX ) );
104            $data['messages'][$message] = $encoded;
105        } else {
106            $data[$key] = $encoded;
107        }
108    }
109
110    /**
111     * Determine whether this array contains only scalar values.
112     *
113     * @param array $arr
114     * @return bool
115     */
116    private static function isValueArray( array $arr ) {
117        foreach ( $arr as $value ) {
118            if ( is_scalar( $value )
119                || $value === null
120                || ( is_array( $value ) && self::isValueArray( $value ) )
121            ) {
122                continue;
123            }
124            return false;
125        }
126        return true;
127    }
128
129    /**
130     * Encodes a value into an array format
131     *
132     * @param mixed $value
133     * @return array|mixed
134     * @throws RuntimeException
135     */
136    public static function encode( $value ) {
137        if ( is_array( $value ) && self::isValueArray( $value ) ) {
138            // Type: scalar [v]alue.
139            // Optimization: Write large arrays as one value to avoid recursive decoding cost.
140            return [ 'v', $value ];
141        }
142        if ( is_array( $value ) || is_object( $value ) ) {
143            // Type: [s]serialized.
144            // Optimization: Avoid recursive decoding cost. Write arrays with an objects
145            // as one serialised value.
146            return [ 's', serialize( $value ) ];
147        }
148        if ( is_scalar( $value ) || $value === null ) {
149            // Optimization: Reduce file size by not wrapping scalar values.
150            return $value;
151        }
152
153        throw new RuntimeException( 'Cannot encode ' . var_export( $value, true ) );
154    }
155
156    /**
157     * Decode something that was encoded with 'encode'
158     *
159     * @param mixed $encoded
160     * @return array|mixed
161     * @throws RuntimeException
162     */
163    public static function decode( $encoded ) {
164        if ( !is_array( $encoded ) ) {
165            // Unwrapped scalar value
166            return $encoded;
167        }
168
169        [ $type, $data ] = $encoded;
170
171        switch ( $type ) {
172            case 'v':
173                // Value array (1.35+) or unwrapped scalar value (1.32 and earlier)
174                return $data;
175            case 's':
176                return unserialize( $data );
177            case 'a':
178                // Support: MediaWiki 1.34 and earlier (older file format)
179                return array_map( self::decode( ... ), $data );
180            default:
181                throw new RuntimeException(
182                    'Unable to decode ' . var_export( $encoded, true ) );
183        }
184    }
185
186    public function finishWrite() {
187        $this->atomicWrite( $this->fname, $this->data[$this->currentLang] );
188
189        // Release the data to manage the memory in rebuildLocalisationCache
190        unset( $this->data[$this->currentLang] );
191        $this->currentLang = null;
192        $this->fname = null;
193    }
194
195    protected function atomicWrite( string $fileName, array $data ): void {
196        $writer = new StaticArrayWriter();
197        $out = $writer->create(
198            $data,
199            'Generated by LCStoreStaticArray.php -- do not edit!'
200        );
201        // Don't just write to the file, since concurrent requests may see a partial file (T304515).
202        // Write to a file in the same filesystem so that it can be atomically moved.
203        $tmpFileName = "$fileName.tmp." . getmypid() . '.' . mt_rand();
204        file_put_contents( $tmpFileName, $out );
205
206        rename( $tmpFileName, $fileName );
207    }
208
209    protected function getMessageKey( string $key ): ?string {
210        if ( str_starts_with( $key, self::MESSAGES_PREFIX ) ) {
211            return substr( $key, strlen( self::MESSAGES_PREFIX ) );
212        }
213        return null;
214    }
215
216    /** @inheritDoc */
217    public function get( $code, $key ) {
218        if ( !$this->loadLanguage( $code ) ) {
219            return null;
220        }
221        $data =& $this->data[$code];
222
223        $value = $data[$key] ?? null;
224
225        if ( $value === null ) {
226            // Reassembled split keys are new in 1.46, reducing redundancy in the files.
227            if ( str_starts_with( $key, self::MESSAGES_PREFIX ) ) {
228                $message = $this->getMessageKey( $key );
229                $value = $data['messages'][$message] ?? null;
230            }
231        }
232
233        $value = $this->decode( $value );
234
235        if ( $key === 'list' ) {
236            if ( !isset( $value['messages'] ) ) {
237                // No need to store per-language, we have the list
238                $value['messages'] = array_keys( $data['messages'] ?? [] );
239            }
240        }
241
242        return $value;
243    }
244
245    private function loadLanguage( string $code ): bool {
246        if ( !array_key_exists( $code, $this->data ) ) {
247            $fname = $this->directory . '/' . $code . '.l10n.php';
248            if ( !is_file( $fname ) ) {
249                return false;
250            }
251            $data = require $fname;
252            if ( !is_array( $data ) ) {
253                return false;
254            }
255            $this->data[$code] = $data;
256        }
257        return true;
258    }
259
260    /** @inheritDoc */
261    public function lateFallback(): bool {
262        return true;
263    }
264}
265
266/** @deprecated class alias since 1.46 */
267class_alias( LCStoreStaticArray::class, 'LCStoreStaticArray' );