3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 use Wikimedia\Rdbms\DatabaseDomain
;
22 use Wikimedia\Rdbms\DBUnexpectedError
;
23 use Wikimedia\Rdbms\ILoadBalancer
;
24 use Wikimedia\Rdbms\LBFactory
;
25 use Wikimedia\Rdbms\Query
;
26 use Wikimedia\Rdbms\ServerInfo
;
27 use Wikimedia\ScopedCallback
;
30 * External storage in a SQL database.
32 * In this system, each store "location" maps to a database "cluster".
33 * The clusters must be defined in the normal LBFactory configuration.
35 * @see ExternalStoreAccess
36 * @ingroup ExternalStorage
38 class ExternalStoreDB
extends ExternalStoreMedium
{
43 * @see ExternalStoreMedium::__construct()
44 * @param array $params Additional parameters include:
45 * - lbFactory: an LBFactory instance
47 public function __construct( array $params ) {
48 parent
::__construct( $params );
49 if ( !isset( $params['lbFactory'] ) ||
!( $params['lbFactory'] instanceof LBFactory
) ) {
50 throw new InvalidArgumentException( "LBFactory required in 'lbFactory' field." );
52 $this->lbFactory
= $params['lbFactory'];
56 * Fetch data from given external store URL.
58 * The provided URL is in the form of `DB://cluster/id` or `DB://cluster/id/itemid`
59 * for concatenated storage if ConcatenatedGzipHistoryBlob was used.
62 * @return string|false False if missing
63 * @see ExternalStoreMedium::fetchFromURL()
65 public function fetchFromURL( $url ) {
66 [ $cluster, $id, $itemID ] = $this->parseURL( $url );
67 $ret = $this->fetchBlob( $cluster, $id, $itemID );
69 if ( $itemID !== false && $ret !== false ) {
70 return $ret->getItem( $itemID );
77 * Fetch multiple URLs from given external store.
79 * The provided URLs are in the form of `DB://cluster/id`, or `DB://cluster/id/itemid`
80 * for concatenated storage if ConcatenatedGzipHistoryBlob was used.
82 * @param array $urls An array of external store URLs
83 * @return array A map from url to stored content. Failed results
84 * are not represented.
86 public function batchFetchFromURLs( array $urls ) {
87 $batched = $inverseUrlMap = [];
88 foreach ( $urls as $url ) {
89 [ $cluster, $id, $itemID ] = $this->parseURL( $url );
90 $batched[$cluster][$id][] = $itemID;
91 // false $itemID gets cast to int, but should be ok
92 // since we do === from the $itemID in $batched
93 $inverseUrlMap[$cluster][$id][$itemID] = $url;
96 foreach ( $batched as $cluster => $batchByCluster ) {
97 $res = $this->batchFetchBlobs( $cluster, $batchByCluster );
98 /** @var HistoryBlob $blob */
99 foreach ( $res as $id => $blob ) {
100 foreach ( $batchByCluster[$id] as $itemID ) {
101 $url = $inverseUrlMap[$cluster][$id][$itemID];
102 if ( $itemID === false ) {
105 $ret[$url] = $blob->getItem( $itemID );
117 public function store( $location, $data ) {
118 $blobsTable = $this->getTable( $location );
120 $dbw = $this->getPrimary( $location );
121 $dbw->newInsertQueryBuilder()
122 ->insertInto( $blobsTable )
123 ->row( [ 'blob_text' => $data ] )
124 ->caller( __METHOD__
)->execute();
126 $id = $dbw->insertId();
128 throw new ExternalStoreException( __METHOD__
. ': no insert ID' );
131 return "DB://$location/$id";
137 public function isReadOnly( $location ) {
138 if ( parent
::isReadOnly( $location ) ) {
142 return ( $this->getLoadBalancer( $location )->getReadOnlyReason() !== false );
146 * Get a LoadBalancer for the specified cluster
148 * @param string $cluster Cluster name
149 * @return ILoadBalancer
151 private function getLoadBalancer( $cluster ) {
152 return $this->lbFactory
->getExternalLB( $cluster );
156 * Get a replica DB connection for the specified cluster
159 * @param string $cluster Cluster name
160 * @return \Wikimedia\Rdbms\IReadableDatabase
162 public function getReplica( $cluster ) {
163 $lb = $this->getLoadBalancer( $cluster );
165 return $lb->getConnection(
168 $this->getDomainId( $lb->getServerInfo( ServerInfo
::WRITER_INDEX
) ),
169 $lb::CONN_TRX_AUTOCOMMIT
174 * Get a primary database connection for the specified cluster
176 * @param string $cluster Cluster name
177 * @return \Wikimedia\Rdbms\IMaintainableDatabase
180 public function getPrimary( $cluster ) {
181 $lb = $this->getLoadBalancer( $cluster );
183 return $lb->getMaintenanceConnectionRef(
186 $this->getDomainId( $lb->getServerInfo( ServerInfo
::WRITER_INDEX
) ),
187 $lb::CONN_TRX_AUTOCOMMIT
192 * @param array $server Primary DB server configuration array for LoadBalancer
193 * @return string|false Database domain ID or false
195 private function getDomainId( array $server ) {
196 if ( $this->isDbDomainExplicit
) {
197 return $this->dbDomain
; // explicit foreign domain
200 if ( isset( $server['dbname'] ) ) {
201 // T200471: for b/c, treat any "dbname" field as forcing which database to use.
202 // MediaWiki/LoadBalancer previously did not enforce any concept of a local DB
203 // domain, but rather assumed that the LB server configuration matched $wgDBname.
204 // This check is useful when the external storage DB for this cluster does not use
205 // the same name as the corresponding "main" DB(s) for wikis.
206 $domain = new DatabaseDomain(
208 $server['schema'] ??
null,
209 $server['tablePrefix'] ??
''
212 return $domain->getId();
215 return false; // local LB domain
219 * Get the configured blobs table name for this database
221 * Typically, a suffix like "_clusterX" can be used to facilitate clean merging of
222 * read-only storage clusters by simply cloning tables to the new cluster servers.
224 * @param string $cluster Cluster name
225 * @return string Unqualified table name (e.g. "blobs_cluster32" or default "blobs")
226 * @internal Only for use within ExternalStoreDB and its core maintenance scripts
228 public function getTable( string $cluster ) {
229 $lb = $this->getLoadBalancer( $cluster );
230 $info = $lb->getServerInfo( ServerInfo
::WRITER_INDEX
);
232 return $info['blobs table'] ??
'blobs';
236 * Create the appropriate blobs table on this cluster
239 * @param string $cluster
241 public function initializeTable( $cluster ) {
244 static $supportedTypes = [ 'mysql', 'sqlite' ];
246 $dbw = $this->getPrimary( $cluster );
247 if ( !in_array( $dbw->getType(), $supportedTypes, true ) ) {
248 throw new DBUnexpectedError( $dbw, "RDBMS type '{$dbw->getType()}' not supported." );
251 $sqlFilePath = "$IP/maintenance/storage/blobs.sql";
252 $sql = file_get_contents( $sqlFilePath );
253 if ( $sql === false ) {
254 throw new RuntimeException( "Failed to read '$sqlFilePath'." );
257 $blobsTable = $this->getTable( $cluster );
258 $encTable = $dbw->tableName( $blobsTable );
259 $sqlWithReplacedVars = str_replace(
260 [ '/*$wgDBprefix*/blobs', '/*_*/blobs' ],
261 [ $encTable, $encTable ],
267 $sqlWithReplacedVars,
268 $dbw::QUERY_CHANGE_SCHEMA
,
278 * Fetch a blob item out of the database; a cache of the last-loaded
279 * blob will be kept so that multiple loads out of a multi-item blob
280 * can avoid redundant database access and decompression.
281 * @param string $cluster
283 * @param string $itemID
284 * @return HistoryBlob|false Returns false if missing
286 private function fetchBlob( $cluster, $id, $itemID ) {
288 * One-step cache variable to hold base blobs; operations that
289 * pull multiple revisions may often pull multiple times from
290 * the same blob. By keeping the last-used one open, we avoid
291 * redundant unserialization and decompression overhead.
293 static $externalBlobCache = [];
295 $cacheID = ( $itemID === false ) ?
"$cluster/$id" : "$cluster/$id/";
296 $cacheID = "$cacheID@{$this->dbDomain}";
298 if ( isset( $externalBlobCache[$cacheID] ) ) {
299 $this->logger
->debug( __METHOD__
. ": cache hit on $cacheID" );
301 return $externalBlobCache[$cacheID];
304 $this->logger
->debug( __METHOD__
. ": cache miss on $cacheID" );
306 $blobsTable = $this->getTable( $cluster );
308 $dbr = $this->getReplica( $cluster );
309 $ret = $dbr->newSelectQueryBuilder()
310 ->select( 'blob_text' )
311 ->from( $blobsTable )
312 ->where( [ 'blob_id' => $id ] )
313 ->caller( __METHOD__
)->fetchField();
315 if ( $ret === false ) {
316 // Try the primary DB
317 $this->logger
->warning( __METHOD__
. ": primary DB fallback on $cacheID" );
318 $trxProfiler = $this->lbFactory
->getTransactionProfiler();
319 $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY
);
320 $dbw = $this->getPrimary( $cluster );
321 $ret = $dbw->newSelectQueryBuilder()
322 ->select( 'blob_text' )
323 ->from( $blobsTable )
324 ->where( [ 'blob_id' => $id ] )
325 ->caller( __METHOD__
)->fetchField();
326 ScopedCallback
::consume( $scope );
327 if ( $ret === false ) {
328 $this->logger
->warning( __METHOD__
. ": primary DB failed to find $cacheID" );
331 if ( $itemID !== false && $ret !== false ) {
332 // Unserialise object; caller extracts item
333 $ret = HistoryBlobUtils
::unserialize( $ret );
336 $externalBlobCache = [ $cacheID => $ret ];
342 * Fetch multiple blob items out of the database
344 * @param string $cluster A cluster name valid for use with LBFactory
345 * @param array $ids A map from the blob_id's to look for to the requested itemIDs in the blobs
346 * @return array A map from the blob_id's requested to their content.
347 * Unlocated ids are not represented
349 private function batchFetchBlobs( $cluster, array $ids ) {
350 $blobsTable = $this->getTable( $cluster );
352 $dbr = $this->getReplica( $cluster );
353 $res = $dbr->newSelectQueryBuilder()
354 ->select( [ 'blob_id', 'blob_text' ] )
355 ->from( $blobsTable )
356 ->where( [ 'blob_id' => array_keys( $ids ) ] )
357 ->caller( __METHOD__
)
361 $this->mergeBatchResult( $ret, $ids, $res );
365 __METHOD__
. ": primary fallback on '$cluster' for: " .
366 implode( ',', array_keys( $ids ) )
368 $trxProfiler = $this->lbFactory
->getTransactionProfiler();
369 $scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY
);
370 $dbw = $this->getPrimary( $cluster );
371 $res = $dbw->newSelectQueryBuilder()
372 ->select( [ 'blob_id', 'blob_text' ] )
373 ->from( $blobsTable )
374 ->where( [ 'blob_id' => array_keys( $ids ) ] )
375 ->caller( __METHOD__
)
377 ScopedCallback
::consume( $scope );
378 $this->mergeBatchResult( $ret, $ids, $res );
381 $this->logger
->error(
382 __METHOD__
. ": primary on '$cluster' failed locating items: " .
383 implode( ',', array_keys( $ids ) )
391 * Helper function for self::batchFetchBlobs for merging primary/replica DB results
392 * @param array &$ret Current self::batchFetchBlobs return value
393 * @param array &$ids Map from blob_id to requested itemIDs
394 * @param mixed $res DB result from Database::select
396 private function mergeBatchResult( array &$ret, array &$ids, $res ) {
397 foreach ( $res as $row ) {
399 $itemIDs = $ids[$id];
400 unset( $ids[$id] ); // to track if everything is found
401 if ( count( $itemIDs ) === 1 && reset( $itemIDs ) === false ) {
402 // single result stored per blob
403 $ret[$id] = $row->blob_text
;
405 // multi result stored per blob
406 $ret[$id] = HistoryBlobUtils
::unserialize( $row->blob_text
);
415 protected function parseURL( $url ) {
416 $path = explode( '/', $url );
421 $path[4] ??
false // itemID
426 * Get the cluster part of a URL
428 * @internal for installer
430 * @return string|null
432 public function getClusterForUrl( $url ) {
433 $parts = explode( '/', $url );
434 return $parts[2] ??
null;
438 * Get the domain ID for a given cluster, which is false for the local wiki ID
440 * @internal for installer
441 * @param string $cluster
442 * @return string|false
444 public function getDomainIdForCluster( $cluster ) {
445 $lb = $this->getLoadBalancer( $cluster );
446 return $this->getDomainId( $lb->getServerInfo( ServerInfo
::WRITER_INDEX
) );