loadBalancer = $dbLoadBalancer; $this->cache = $cache; $this->logger = $logger; $this->table = $table; $this->idField = $idField; $this->nameField = $nameField; $this->normalizationCallback = $normalizationCallback; $this->wikiId = $wikiId; $this->cacheTTL = IExpiringStore::TTL_MONTH; } /** * @param int $index A database index, like DB_MASTER or DB_REPLICA * @param int $flags Database connection flags * * @return IDatabase */ private function getDBConnection( $index, $flags = 0 ) { return $this->loadBalancer->getConnection( $index, [], $this->wikiId, $flags ); } private function getCacheKey() { return $this->cache->makeKey( 'NameTableSqlStore', $this->table, $this->wikiId ); } /** * @param string $name * @return string */ private function normalizeName( $name ) { if ( $this->normalizationCallback === null ) { return $name; } return call_user_func( $this->normalizationCallback, $name ); } /** * Acquire the id of the given name. * This creates a row in the table if it doesn't already exist. * * @param string $name * @throws NameTableAccessException * @return int */ public function acquireId( $name ) { Assert::parameterType( 'string', $name, '$name' ); $name = $this->normalizeName( $name ); $table = $this->getTableFromCachesOrReplica(); $searchResult = array_search( $name, $table, true ); if ( $searchResult === false ) { $id = $this->store( $name ); if ( $id === null ) { // RACE: $name was already in the db, probably just inserted, so load from master // Use DBO_TRX to avoid missing inserts due to other threads or REPEATABLE-READs $table = $this->loadTable( $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTOCOMMIT ) ); $searchResult = array_search( $name, $table, true ); if ( $searchResult === false ) { // Insert failed due to IGNORE flag, but DB_MASTER didn't give us the data $m = "No insert possible but master didn't give us a record for " . "'{$name}' in '{$this->table}'"; $this->logger->error( $m ); throw new NameTableAccessException( $m ); } $this->purgeWANCache( function () { $this->cache->reap( $this->getCacheKey(), INF ); } ); } else { $table[$id] = $name; $searchResult = $id; // As store returned an ID we know we inserted so delete from WAN cache $this->purgeWANCache( function () { $this->cache->delete( $this->getCacheKey() ); } ); } $this->tableCache = $table; } return $searchResult; } /** * Get the id of the given name. * If the name doesn't exist this will throw. * This should be used in cases where we believe the name already exists or want to check for * existence. * * @param string $name * @throws NameTableAccessException The name does not exist * @return int Id */ public function getId( $name ) { Assert::parameterType( 'string', $name, '$name' ); $name = $this->normalizeName( $name ); $table = $this->getTableFromCachesOrReplica(); $searchResult = array_search( $name, $table, true ); if ( $searchResult !== false ) { return $searchResult; } throw NameTableAccessException::newFromDetails( $this->table, 'name', $name ); } /** * Get the name of the given id. * If the id doesn't exist this will throw. * This should be used in cases where we believe the id already exists. * * Note: Calls to this method will result in a master select for non existing IDs. * * @param int $id * @throws NameTableAccessException The id does not exist * @return string name */ public function getName( $id ) { Assert::parameterType( 'integer', $id, '$id' ); $table = $this->getTableFromCachesOrReplica(); if ( array_key_exists( $id, $table ) ) { return $table[$id]; } $table = $this->cache->getWithSetCallback( $this->getCacheKey(), $this->cacheTTL, function ( $oldValue, &$ttl, &$setOpts ) use ( $id ) { // Check if cached value is up-to-date enough to have $id if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) { // Completely leave the cache key alone $ttl = WANObjectCache::TTL_UNCACHEABLE; // Use the old value return $oldValue; } // Regenerate from replica DB, and master DB if needed foreach ( [ DB_REPLICA, DB_MASTER ] as $source ) { // Log a fallback to master if ( $source === DB_MASTER ) { $this->logger->info( __METHOD__ . 'falling back to master select from ' . $this->table . ' with id ' . $id ); } $db = $this->getDBConnection( $source ); $cacheSetOpts = Database::getCacheSetOptions( $db ); $table = $this->loadTable( $db ); if ( array_key_exists( $id, $table ) ) { break; // found it } } // Use the value from last source checked $setOpts += $cacheSetOpts; return $table; }, [ 'minAsOf' => INF ] // force callback run ); $this->tableCache = $table; if ( array_key_exists( $id, $table ) ) { return $table[$id]; } throw NameTableAccessException::newFromDetails( $this->table, 'id', $id ); } /** * Get the whole table, in no particular order as a map of ids to names. * This method could be subject to DB or cache lag. * * @return string[] keys are the name ids, values are the names themselves * Example: [ 1 => 'foo', 3 => 'bar' ] */ public function getMap() { return $this->getTableFromCachesOrReplica(); } /** * @return string[] */ private function getTableFromCachesOrReplica() { if ( $this->tableCache !== null ) { return $this->tableCache; } $table = $this->cache->getWithSetCallback( $this->getCacheKey(), $this->cacheTTL, function ( $oldValue, &$ttl, &$setOpts ) { $dbr = $this->getDBConnection( DB_REPLICA ); $setOpts += Database::getCacheSetOptions( $dbr ); return $this->loadTable( $dbr ); } ); $this->tableCache = $table; return $table; } /** * Reap the WANCache entry for this table. * * @param callable $purgeCallback callback to 'purge' the WAN cache */ private function purgeWANCache( $purgeCallback ) { // If the LB has no DB changes don't both with onTransactionPreCommitOrIdle if ( !$this->loadBalancer->hasOrMadeRecentMasterChanges() ) { $purgeCallback(); return; } $this->getDBConnection( DB_MASTER ) ->onTransactionPreCommitOrIdle( $purgeCallback, __METHOD__ ); } /** * Gets the table from the db * * @param IDatabase $db * * @return string[] */ private function loadTable( IDatabase $db ) { $result = $db->select( $this->table, [ 'id' => $this->idField, 'name' => $this->nameField ], [], __METHOD__, [ 'ORDER BY' => 'id' ] ); $assocArray = []; foreach ( $result as $row ) { $assocArray[$row->id] = $row->name; } return $assocArray; } /** * Stores the given name in the DB, returning the ID when an insert occurs. * * @param string $name * @return int|null int if we know the ID, null if we don't */ private function store( $name ) { Assert::parameterType( 'string', $name, '$name' ); Assert::parameter( $name !== '', '$name', 'should not be an empty string' ); // Note: this is only called internally so normalization of $name has already occurred. $dbw = $this->getDBConnection( DB_MASTER ); $dbw->insert( $this->table, [ $this->nameField => $name ], __METHOD__, [ 'IGNORE' ] ); if ( $dbw->affectedRows() === 0 ) { $this->logger->info( 'Tried to insert name into table ' . $this->table . ', but value already existed.' ); return null; } return $dbw->insertId(); } }