Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.92% covered (warning)
79.92%
207 / 259
74.07% covered (warning)
74.07%
20 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlBlobStore
79.92% covered (warning)
79.92%
207 / 259
74.07% covered (warning)
74.07%
20 / 27
160.50
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
 getCacheExpiry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCacheExpiry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCompressBlobs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCompressBlobs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLegacyEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLegacyEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUseExternalStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUseExternalStore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBLoadBalancer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDBConnection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 storeBlob
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
5.33
 getBlob
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
4
 getBlobBatch
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 fetchBlobs
68.04% covered (warning)
68.04%
66 / 97
0.00% covered (danger)
0.00%
0 / 1
37.80
 getDBOptions
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
8.69
 getCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getCacheOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 expandBlob
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
9.14
 compressData
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 decompressData
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
11
 getCacheTTL
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getTextIdFromAddress
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 makeAddressFromTextId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 explodeFlags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 splitBlobAddress
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 isReadOnly
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * Attribution notice: when this file was created, much of its content was taken
5 * from the Revision.php file as present in release 1.30. Refer to the history
6 * of that file for original authorship (that file was removed entirely in 1.37,
7 * but its history can still be found in prior versions of MediaWiki).
8 *
9 * @file
10 */
11
12namespace MediaWiki\Storage;
13
14use AppendIterator;
15use HistoryBlobUtils;
16use InvalidArgumentException;
17use MediaWiki\ExternalStore\ExternalStoreAccess;
18use MediaWiki\ExternalStore\ExternalStoreException;
19use StatusValue;
20use Wikimedia\Assert\Assert;
21use Wikimedia\ObjectCache\BagOStuff;
22use Wikimedia\ObjectCache\WANObjectCache;
23use Wikimedia\Rdbms\DBAccessObjectUtils;
24use Wikimedia\Rdbms\IDatabase;
25use Wikimedia\Rdbms\IDBAccessObject;
26use Wikimedia\Rdbms\ILoadBalancer;
27
28/**
29 * Service for storing and loading Content objects representing revision data blobs.
30 *
31 * @since 1.31
32 *
33 * @note This was written to act as a drop-in replacement for the corresponding
34 *       static methods in the old Revision class (which was later removed in 1.37).
35 */
36class SqlBlobStore implements BlobStore {
37
38    // Note: the name has been taken unchanged from the old Revision class.
39    public const TEXT_CACHE_GROUP = 'revisiontext:10';
40
41    /** @internal */
42    public const DEFAULT_TTL = 7 * 24 * 3600; // 7 days
43
44    /**
45     * @var int
46     */
47    private $cacheExpiry = self::DEFAULT_TTL;
48
49    /**
50     * @var bool
51     */
52    private $compressBlobs = false;
53
54    /**
55     * @var string|false
56     */
57    private $legacyEncoding = false;
58
59    /**
60     * @var bool
61     */
62    private $useExternalStore = false;
63
64    /**
65     * @param ILoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
66     * @param ExternalStoreAccess $extStoreAccess Access layer for external storage
67     * @param WANObjectCache $cache A cache manager for caching blobs. This can be the local
68     *        wiki's default instance even if $dbDomain refers to a different wiki, since
69     *        makeGlobalKey() is used to construct a key that allows cached blobs from the
70     *        same database to be re-used between wikis. For example, wiki A and wiki B will
71     *        use the same cache keys for blobs fetched from wiki C, regardless of the
72     *        wiki-specific default key space.
73     * @param bool|string $dbDomain The ID of the target wiki database. Use false for the local wiki.
74     */
75    public function __construct(
76        private readonly ILoadBalancer $dbLoadBalancer,
77        private readonly ExternalStoreAccess $extStoreAccess,
78        private readonly WANObjectCache $cache,
79        private readonly bool|string $dbDomain = false,
80    ) {
81    }
82
83    /**
84     * @return int Time for which blobs can be cached, in seconds
85     */
86    public function getCacheExpiry() {
87        return $this->cacheExpiry;
88    }
89
90    /**
91     * @param int $cacheExpiry Time for which blobs can be cached, in seconds
92     */
93    public function setCacheExpiry( int $cacheExpiry ) {
94        $this->cacheExpiry = $cacheExpiry;
95    }
96
97    /**
98     * @return bool Whether blobs should be compressed for storage
99     */
100    public function getCompressBlobs() {
101        return $this->compressBlobs;
102    }
103
104    /**
105     * @param bool $compressBlobs Whether blobs should be compressed for storage
106     */
107    public function setCompressBlobs( $compressBlobs ) {
108        $this->compressBlobs = $compressBlobs;
109    }
110
111    /**
112     * @return false|string The legacy encoding to assume for blobs that are not marked as utf8.
113     *         False means handling of legacy encoding is disabled, and utf8 assumed.
114     */
115    public function getLegacyEncoding() {
116        return $this->legacyEncoding;
117    }
118
119    /**
120     * Set the legacy encoding to assume for blobs that do not have the utf-8 flag set.
121     *
122     * @note The second parameter, Language $language, was removed in 1.34.
123     *
124     * @param string $legacyEncoding The legacy encoding to assume for blobs that are
125     *        not marked as utf8.
126     */
127    public function setLegacyEncoding( string $legacyEncoding ) {
128        $this->legacyEncoding = $legacyEncoding;
129    }
130
131    /**
132     * @return bool Whether to use the ExternalStore mechanism for storing blobs.
133     */
134    public function getUseExternalStore() {
135        return $this->useExternalStore;
136    }
137
138    /**
139     * @param bool $useExternalStore Whether to use the ExternalStore mechanism for storing blobs.
140     */
141    public function setUseExternalStore( bool $useExternalStore ) {
142        $this->useExternalStore = $useExternalStore;
143    }
144
145    /**
146     * @return ILoadBalancer
147     */
148    private function getDBLoadBalancer() {
149        return $this->dbLoadBalancer;
150    }
151
152    /**
153     * @param int $index A database index, like DB_PRIMARY or DB_REPLICA
154     *
155     * @return IDatabase
156     */
157    private function getDBConnection( $index ) {
158        $lb = $this->getDBLoadBalancer();
159        return $lb->getConnection( $index, [], $this->dbDomain );
160    }
161
162    /**
163     * Stores an arbitrary blob of data and returns an address that can be used with
164     * getBlob() to retrieve the same blob of data,
165     *
166     * @param string $data
167     * @param array $hints An array of hints.
168     *
169     * @throws BlobAccessException
170     * @return string an address that can be used with getBlob() to retrieve the data.
171     */
172    public function storeBlob( $data, $hints = [] ) {
173        $flags = $this->compressData( $data );
174
175        # Write to external storage if required
176        if ( $this->useExternalStore ) {
177            // Store and get the URL
178            try {
179                $data = $this->extStoreAccess->insert( $data, [ 'domain' => $this->dbDomain ] );
180            } catch ( ExternalStoreException $e ) {
181                throw new BlobAccessException( $e->getMessage(), 0, $e );
182            }
183            if ( !$data ) {
184                throw new BlobAccessException( "Failed to store text to external storage" );
185            }
186            if ( $flags ) {
187                return 'es:' . $data . '?flags=' . $flags;
188            } else {
189                return 'es:' . $data;
190            }
191        } else {
192            $dbw = $this->getDBConnection( DB_PRIMARY );
193
194            $dbw->newInsertQueryBuilder()
195                ->insertInto( 'text' )
196                ->row( [ 'old_text' => $data, 'old_flags' => $flags ] )
197                ->caller( __METHOD__ )->execute();
198
199            $textId = $dbw->insertId();
200
201            return self::makeAddressFromTextId( $textId );
202        }
203    }
204
205    /**
206     * Retrieve a blob, given an address.
207     * Currently hardcoded to the 'text' table storage engine.
208     *
209     * MCR migration note: this replaced Revision::loadText
210     *
211     * @param string $blobAddress
212     * @param int $queryFlags
213     *
214     * @throws BlobAccessException
215     * @return string
216     */
217    public function getBlob( $blobAddress, $queryFlags = 0 ) {
218        Assert::parameterType( 'string', $blobAddress, '$blobAddress' );
219
220        $error = null;
221        $blob = $this->cache->getWithSetCallback(
222            $this->getCacheKey( $blobAddress ),
223            $this->getCacheTTL(),
224            function ( $unused, &$ttl, &$setOpts ) use ( $blobAddress, $queryFlags, &$error ) {
225                // Ignore $setOpts; blobs are immutable and negatives are not cached
226                [ $result, $errors ] = $this->fetchBlobs( [ $blobAddress ], $queryFlags );
227                // No negative caching; negative hits on text rows may be due to corrupted replica DBs
228                $error = $errors[$blobAddress] ?? null;
229                if ( $error ) {
230                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
231                }
232                return $result[$blobAddress];
233            },
234            $this->getCacheOptions()
235        );
236
237        if ( $error ) {
238            if ( $error[0] === 'badrevision' ) {
239                throw new BadBlobException( $error[1] );
240            } else {
241                throw new BlobAccessException( $error[1] );
242            }
243        }
244
245        Assert::postcondition( is_string( $blob ), 'Blob must not be null' );
246        return $blob;
247    }
248
249    /**
250     * A batched version of BlobStore::getBlob.
251     *
252     * @param string[] $blobAddresses An array of blob addresses.
253     * @param int $queryFlags See IDBAccessObject.
254     * @throws BlobAccessException
255     * @return StatusValue A status with a map of blobAddress => binary blob data or null
256     *         if fetching the blob has failed. Fetch failures errors are the
257     *         warnings in the status object.
258     * @since 1.34
259     */
260    public function getBlobBatch( $blobAddresses, $queryFlags = 0 ) {
261        // FIXME: All caching has temporarily been removed in I94c6f9ba7b9caeeb due to T235188.
262        //        Caching behavior should be restored by reverting I94c6f9ba7b9caeeb as soon as
263        //        the root cause of T235188 has been resolved.
264
265        [ $blobsByAddress, $errors ] = $this->fetchBlobs( $blobAddresses, $queryFlags );
266
267        $blobsByAddress = array_map( static function ( $blob ) {
268            return $blob === false ? null : $blob;
269        }, $blobsByAddress );
270
271        $result = StatusValue::newGood( $blobsByAddress );
272        foreach ( $errors as $error ) {
273            // @phan-suppress-next-line PhanParamTooFewUnpack
274            $result->warning( ...$error );
275        }
276        return $result;
277    }
278
279    /**
280     * MCR migration note: this corresponded to Revision::fetchText
281     *
282     * @param string[] $blobAddresses
283     * @param int $queryFlags
284     *
285     * @throws BlobAccessException
286     * @return array [ $result, $errors ] A list with the following elements:
287     *   - The result: a map of blob addresses to successfully fetched blobs
288     *     or false if fetch failed
289     *   - Errors: a map of blob addresses to error information about the blob.
290     *     On success, the relevant key will be absent. Each error is a list of
291     *     parameters to be passed to StatusValue::warning().
292     */
293    private function fetchBlobs( $blobAddresses, $queryFlags ) {
294        $textIdToBlobAddress = [];
295        $result = [];
296        $errors = [];
297        foreach ( $blobAddresses as $blobAddress ) {
298            try {
299                [ $schema, $id, $params ] = self::splitBlobAddress( $blobAddress );
300            } catch ( InvalidArgumentException $ex ) {
301                throw new BlobAccessException(
302                    $ex->getMessage() . '. Use findBadBlobs.php to remedy.',
303                    0,
304                    $ex
305                );
306            }
307
308            if ( $schema === 'es' ) {
309                if ( $params && isset( $params['flags'] ) ) {
310                    $blob = $this->expandBlob( $id, $params['flags'] . ',external', $blobAddress );
311                } else {
312                    $blob = $this->expandBlob( $id, 'external', $blobAddress );
313                }
314
315                if ( $blob === false ) {
316                    $errors[$blobAddress] = [
317                        'internalerror',
318                        "Bad data in external store address $id. Use findBadBlobs.php to remedy."
319                    ];
320                }
321                $result[$blobAddress] = $blob;
322            } elseif ( $schema === 'bad' ) {
323                // Database row was marked as "known bad"
324                wfDebug(
325                    __METHOD__
326                    . ": loading known-bad content ($blobAddress), returning empty string"
327                );
328                $result[$blobAddress] = '';
329                $errors[$blobAddress] = [
330                    'badrevision',
331                    'The content of this revision is missing or corrupted (bad schema)'
332                ];
333            } elseif ( $schema === 'tt' ) {
334                $textId = intval( $id );
335
336                if ( $textId < 1 || $id !== (string)$textId ) {
337                    $errors[$blobAddress] = [
338                        'internalerror',
339                        "Bad blob address: $blobAddress. Use findBadBlobs.php to remedy."
340                    ];
341                    $result[$blobAddress] = false;
342                }
343
344                $textIdToBlobAddress[$textId] = $blobAddress;
345            } else {
346                $errors[$blobAddress] = [
347                    'internalerror',
348                    "Unknown blob address schema: $schema. Use findBadBlobs.php to remedy."
349                ];
350                $result[$blobAddress] = false;
351            }
352        }
353
354        $textIds = array_keys( $textIdToBlobAddress );
355        if ( !$textIds ) {
356            return [ $result, $errors ];
357        }
358        // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables
359        // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases.
360        $queryFlags |= DBAccessObjectUtils::hasFlags( $queryFlags, IDBAccessObject::READ_LATEST )
361            ? IDBAccessObject::READ_LATEST_IMMUTABLE
362            : 0;
363        [ $index, $options, $fallbackIndex, $fallbackOptions ] =
364            self::getDBOptions( $queryFlags );
365        // Text data is immutable; check replica DBs first.
366        $dbConnection = $this->getDBConnection( $index );
367        $rows = $dbConnection->newSelectQueryBuilder()
368            ->select( [ 'old_id', 'old_text', 'old_flags' ] )
369            ->from( 'text' )
370            ->where( [ 'old_id' => $textIds ] )
371            ->options( $options )
372            ->caller( __METHOD__ )->fetchResultSet();
373        $numRows = $rows->numRows();
374
375        // Fallback to DB_PRIMARY in some cases if not all the rows were found, using the appropriate
376        // options, such as FOR UPDATE to avoid missing rows due to REPEATABLE-READ.
377        if ( $numRows !== count( $textIds ) && $fallbackIndex !== null ) {
378            $fetchedTextIds = [];
379            foreach ( $rows as $row ) {
380                $fetchedTextIds[] = $row->old_id;
381            }
382            $missingTextIds = array_diff( $textIds, $fetchedTextIds );
383            $dbConnection = $this->getDBConnection( $fallbackIndex );
384            $rowsFromFallback = $dbConnection->newSelectQueryBuilder()
385                ->select( [ 'old_id', 'old_text', 'old_flags' ] )
386                ->from( 'text' )
387                ->where( [ 'old_id' => $missingTextIds ] )
388                ->options( $fallbackOptions )
389                ->caller( __METHOD__ )->fetchResultSet();
390            $appendIterator = new AppendIterator();
391            $appendIterator->append( $rows );
392            $appendIterator->append( $rowsFromFallback );
393            $rows = $appendIterator;
394        }
395
396        foreach ( $rows as $row ) {
397            $blobAddress = $textIdToBlobAddress[$row->old_id];
398            $blob = false;
399            if ( $row->old_text !== null ) {
400                $blob = $this->expandBlob( $row->old_text, $row->old_flags, $blobAddress );
401            }
402            if ( $blob === false ) {
403                $errors[$blobAddress] = [
404                    'internalerror',
405                    "Bad data in text row {$row->old_id}. Use findBadBlobs.php to remedy."
406                ];
407            }
408            $result[$blobAddress] = $blob;
409        }
410
411        // If we're still missing some of the rows, set errors for missing blobs.
412        if ( count( $result ) !== count( $blobAddresses ) ) {
413            foreach ( $blobAddresses as $blobAddress ) {
414                if ( !isset( $result[$blobAddress ] ) ) {
415                    $errors[$blobAddress] = [
416                        'internalerror',
417                        "Unable to fetch blob at $blobAddress. Use findBadBlobs.php to remedy."
418                    ];
419                    $result[$blobAddress] = false;
420                }
421            }
422        }
423        return [ $result, $errors ];
424    }
425
426    private static function getDBOptions( int $bitfield ): array {
427        if ( DBAccessObjectUtils::hasFlags( $bitfield, IDBAccessObject::READ_LATEST_IMMUTABLE ) ) {
428            $index = DB_REPLICA; // override READ_LATEST if set
429            $fallbackIndex = DB_PRIMARY;
430        } elseif ( DBAccessObjectUtils::hasFlags( $bitfield, IDBAccessObject::READ_LATEST ) ) {
431            $index = DB_PRIMARY;
432            $fallbackIndex = null;
433        } else {
434            $index = DB_REPLICA;
435            $fallbackIndex = null;
436        }
437
438        $lockingOptions = [];
439        if ( DBAccessObjectUtils::hasFlags( $bitfield, IDBAccessObject::READ_EXCLUSIVE ) ) {
440            $lockingOptions[] = 'FOR UPDATE';
441        } elseif ( DBAccessObjectUtils::hasFlags( $bitfield, IDBAccessObject::READ_LOCKING ) ) {
442            $lockingOptions[] = 'LOCK IN SHARE MODE';
443        }
444
445        if ( $fallbackIndex !== null ) {
446            $options = []; // locks on DB_REPLICA make no sense
447            $fallbackOptions = $lockingOptions;
448        } else {
449            $options = $lockingOptions;
450            $fallbackOptions = []; // no fallback
451        }
452
453        return [ $index, $options, $fallbackIndex, $fallbackOptions ];
454    }
455
456    /**
457     * Get a cache key for a given Blob address.
458     *
459     * The cache key is constructed in a way that allows cached blobs from the same database
460     * to be re-used between wikis. For example, wiki A and wiki B will use the same cache keys
461     * for blobs fetched from wiki C.
462     *
463     * @param string $blobAddress
464     * @return string
465     */
466    private function getCacheKey( $blobAddress ) {
467        return $this->cache->makeGlobalKey(
468            'SqlBlobStore-blob',
469            $this->dbLoadBalancer->resolveDomainID( $this->dbDomain ),
470            $blobAddress
471        );
472    }
473
474    /**
475     * Get the cache key options for a given Blob
476     *
477     * @return array<string,mixed>
478     */
479    private function getCacheOptions() {
480        return [
481            'pcGroup' => self::TEXT_CACHE_GROUP,
482            'pcTTL' => WANObjectCache::TTL_PROC_LONG,
483            'segmentable' => true
484        ];
485    }
486
487    /**
488     * Expand a raw data blob according to the flags given.
489     *
490     * MCR migration note: this replaced Revision::getRevisionText
491     *
492     * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead.
493     * @todo make this private, there should be no need to use this method outside this class.
494     *
495     * @param string $raw The raw blob data, to be processed according to $flags.
496     *        May be the blob itself, or the blob compressed, or just the address
497     *        of the actual blob, depending on $flags.
498     * @param string|string[] $flags Blob flags, such as 'external' or 'gzip'.
499     *   Note that not including 'utf-8' in $flags will cause the data to be decoded
500     *   according to the legacy encoding specified via setLegacyEncoding.
501     * @param string|null $blobAddress A blob address for use in the cache key. If not given,
502     *   caching is disabled.
503     *
504     * @return false|string The expanded blob or false on failure
505     * @throws BlobAccessException
506     */
507    public function expandBlob( $raw, $flags, $blobAddress = null ) {
508        if ( is_string( $flags ) ) {
509            $flags = self::explodeFlags( $flags );
510        }
511        if ( in_array( 'error', $flags ) ) {
512            throw new BadBlobException(
513                "The content of this revision is missing or corrupted (error flag)"
514            );
515        }
516
517        // Use external methods for external objects, text in table is URL-only then
518        if ( in_array( 'external', $flags ) ) {
519            $url = $raw;
520            $parts = explode( '://', $url, 2 );
521            if ( count( $parts ) == 1 || $parts[1] == '' ) {
522                return false;
523            }
524
525            if ( $blobAddress ) {
526                // The cached value should be decompressed, so handle that and return here.
527                return $this->cache->getWithSetCallback(
528                    $this->getCacheKey( $blobAddress ),
529                    $this->getCacheTTL(),
530                    function () use ( $url, $flags, $blobAddress ) {
531                        // Ignore $setOpts; blobs are immutable and negatives are not cached
532                        $blob = $this->extStoreAccess
533                            ->fetchFromURL( $url, [ 'domain' => $this->dbDomain ] );
534
535                        return $blob === false ? false : $this->decompressData( $blob, $flags, $blobAddress );
536                    },
537                    $this->getCacheOptions()
538                );
539            } else {
540                $blob = $this->extStoreAccess->fetchFromURL( $url, [ 'domain' => $this->dbDomain ] );
541                return $blob === false ? false : $this->decompressData( $blob, $flags, $blobAddress );
542            }
543        } else {
544            return $this->decompressData( $raw, $flags, $blobAddress );
545        }
546    }
547
548    /**
549     * If $wgCompressRevisions is enabled, we will compress data.
550     * The input string is modified in place.
551     * Return value is the flags field: contains 'gzip' if the
552     * data is compressed, and 'utf-8' if we're saving in UTF-8
553     * mode.
554     *
555     * MCR migration note: this replaced Revision::compressRevisionText
556     *
557     * @note direct use is deprecated!
558     * @todo make this private, there should be no need to use this method outside this class.
559     *
560     * @param string &$blob
561     *
562     * @return string
563     */
564    public function compressData( &$blob ) {
565        $blobFlags = [];
566
567        // Revisions not marked as UTF-8 will have legacy decoding applied by decompressData().
568        // XXX: if $this->legacyEncoding is not set, we could skip this. That would however be
569        // risky, since $this->legacyEncoding being set in the future would lead to data corruption.
570        $blobFlags[] = 'utf-8';
571
572        if ( $this->compressBlobs ) {
573            if ( function_exists( 'gzdeflate' ) ) {
574                $deflated = gzdeflate( $blob );
575
576                if ( $deflated === false ) {
577                    wfLogWarning( __METHOD__ . ': gzdeflate() failed' );
578                } else {
579                    $blob = $deflated;
580                    $blobFlags[] = 'gzip';
581                }
582            } else {
583                wfDebug( __METHOD__ . " -- no zlib support, not compressing" );
584            }
585        }
586        return implode( ',', $blobFlags );
587    }
588
589    /**
590     * Re-converts revision text according to its flags.
591     *
592     * MCR migration note: this replaced Revision::decompressRevisionText
593     *
594     * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead.
595     * @todo make this private, there should be no need to use this method outside this class.
596     *
597     * @param string $blob Blob in compressed/encoded form.
598     * @param array $blobFlags Compression flags, such as 'gzip'.
599     *   Note that not including 'utf-8' in $blobFlags will cause the data to be decoded
600     *   according to the legacy encoding specified via setLegacyEncoding.
601     * @param string|null $blobAddress Used for log message
602     *
603     * @return string|false Decompressed text, or false on failure
604     */
605    public function decompressData( string $blob, array $blobFlags, ?string $blobAddress = null ) {
606        if ( in_array( 'error', $blobFlags ) ) {
607            // Error row, return false
608            return false;
609        }
610
611        // Deal with optional compression of archived pages.
612        // This can be done periodically via maintenance/compressOld.php, and
613        // as pages are saved if $wgCompressRevisions is set.
614        if ( in_array( 'gzip', $blobFlags ) ) {
615            // Silence native warning in favour of more detailed warning (T380347)
616            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
617            $blob = @gzinflate( $blob );
618            if ( $blob === false ) {
619                wfWarn( __METHOD__ . ': gzinflate() failed' .
620                    ( $blobAddress ? ' (at blob address ' . $blobAddress . ')' : '' ) );
621                return false;
622            }
623        }
624
625        if ( in_array( 'object', $blobFlags ) ) {
626            # Generic compressed storage
627            $obj = HistoryBlobUtils::unserialize( $blob );
628            if ( !$obj ) {
629                // Invalid object
630                return false;
631            }
632            $blob = $obj->getText();
633        }
634
635        // Needed to support old revisions from before MW 1.5.
636        if ( $blob !== false && $this->legacyEncoding
637            && !in_array( 'utf-8', $blobFlags ) && !in_array( 'utf8', $blobFlags )
638        ) {
639            // - Old revisions kept around in a legacy encoding?
640            //   Upconvert on demand.
641            // - "utf8" checked for compatibility with some broken
642            //   conversion scripts 2008-12-30.
643            // - Even with "//IGNORE" iconv can whine about illegal characters in
644            //   *input* string. We just ignore those too.
645            //   Ref https://bugs.php.net/bug.php?id=37166
646            //   Ref https://phabricator.wikimedia.org/T18885
647            //
648            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
649            $blob = @iconv( $this->legacyEncoding, 'UTF-8//IGNORE', $blob );
650        }
651
652        return $blob;
653    }
654
655    /**
656     * Get the text cache TTL
657     *
658     * MCR migration note: this replaced Revision::getCacheTTL
659     *
660     * @return int
661     */
662    private function getCacheTTL() {
663        $cache = $this->cache;
664
665        if ( $cache->getQoS( BagOStuff::ATTR_DURABILITY ) >= BagOStuff::QOS_DURABILITY_RDBMS ) {
666            // Do not cache RDBMs blobs in...the RDBMs store
667            $ttl = $cache::TTL_UNCACHEABLE;
668        } else {
669            $ttl = $this->cacheExpiry ?: $cache::TTL_UNCACHEABLE;
670        }
671
672        return $ttl;
673    }
674
675    /**
676     * Returns an ID corresponding to the old_id field in the text table, corresponding
677     * to the given $address.
678     *
679     * Currently, $address must start with 'tt:' followed by a decimal integer representing
680     * the old_id; if $address does not start with 'tt:', null is returned. However,
681     * the implementation may change to insert rows into the text table on the fly.
682     * This implies that this method cannot be static.
683     *
684     * @note This method exists for use with the text table based storage schema.
685     * It should not be assumed that is will function with all future kinds of content addresses.
686     *
687     * @deprecated since 1.31, so don't assume that all blob addresses refer to a row in the text
688     * table. This method should become private once the relevant refactoring in WikiPage is
689     * complete.
690     *
691     * @param string $address
692     *
693     * @return int|null
694     */
695    public function getTextIdFromAddress( $address ) {
696        [ $schema, $id, ] = self::splitBlobAddress( $address );
697
698        if ( $schema !== 'tt' ) {
699            return null;
700        }
701
702        $textId = intval( $id );
703
704        if ( !$textId || $id !== (string)$textId ) {
705            throw new InvalidArgumentException( "Malformed text_id: $id" );
706        }
707
708        return $textId;
709    }
710
711    /**
712     * Returns an address referring to content stored in the text table row with the given ID.
713     * The address schema for blobs stored in the text table is "tt:" followed by an integer
714     * that corresponds to a value of the old_id field.
715     *
716     * @internal
717     * @note This method should not be used by regular application logic. It is public so
718     *       maintenance scripts can use it for bulk operations on the text table.
719     *
720     * @param int $id
721     *
722     * @return string
723     */
724    public static function makeAddressFromTextId( $id ) {
725        return 'tt:' . $id;
726    }
727
728    /**
729     * Split a comma-separated old_flags value into its constituent parts
730     *
731     * @param string $flagsString
732     * @return array
733     */
734    public static function explodeFlags( string $flagsString ) {
735        return $flagsString === '' ? [] : explode( ',', $flagsString );
736    }
737
738    /**
739     * Splits a blob address into three parts: the schema, the ID, and parameters/flags.
740     *
741     * @since 1.33
742     *
743     * @param string $address
744     *
745     * @return array [ $schema, $id, $parameters ], with $parameters being an assoc array.
746     */
747    public static function splitBlobAddress( $address ) {
748        if ( !preg_match( '/^([-+.\w]+):([^\s?]+)(\?([^\s]*))?$/', $address, $m ) ) {
749            throw new InvalidArgumentException( "Bad blob address: $address" );
750        }
751
752        $schema = strtolower( $m[1] );
753        $id = $m[2];
754        $parameters = wfCgiToArray( $m[4] ?? '' );
755
756        return [ $schema, $id, $parameters ];
757    }
758
759    /** @inheritDoc */
760    public function isReadOnly() {
761        if ( $this->useExternalStore && $this->extStoreAccess->isReadOnly() ) {
762            return true;
763        }
764
765        return ( $this->getDBLoadBalancer()->getReadOnlyReason() !== false );
766    }
767}