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