NameTableStore.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. <?php
  2. /**
  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.
  7. *
  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.
  12. *
  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
  17. *
  18. * @file
  19. */
  20. namespace MediaWiki\Storage;
  21. use IExpiringStore;
  22. use Psr\Log\LoggerInterface;
  23. use WANObjectCache;
  24. use Wikimedia\Assert\Assert;
  25. use Wikimedia\Rdbms\Database;
  26. use Wikimedia\Rdbms\IDatabase;
  27. use Wikimedia\Rdbms\LoadBalancer;
  28. /**
  29. * @author Addshore
  30. * @since 1.31
  31. */
  32. class NameTableStore {
  33. /** @var LoadBalancer */
  34. private $loadBalancer;
  35. /** @var WANObjectCache */
  36. private $cache;
  37. /** @var LoggerInterface */
  38. private $logger;
  39. /** @var string[] */
  40. private $tableCache = null;
  41. /** @var bool|string */
  42. private $wikiId = false;
  43. /** @var int */
  44. private $cacheTTL;
  45. /** @var string */
  46. private $table;
  47. /** @var string */
  48. private $idField;
  49. /** @var string */
  50. private $nameField;
  51. /** @var null|callable */
  52. private $normalizationCallback = null;
  53. /**
  54. * @param LoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
  55. * @param WANObjectCache $cache A cache manager for caching data
  56. * @param LoggerInterface $logger
  57. * @param string $table
  58. * @param string $idField
  59. * @param string $nameField
  60. * @param callable $normalizationCallback Normalization to be applied to names before being
  61. * saved or queried. This should be a callback that accepts and returns a single string.
  62. * @param bool|string $wikiId The ID of the target wiki database. Use false for the local wiki.
  63. */
  64. public function __construct(
  65. LoadBalancer $dbLoadBalancer,
  66. WANObjectCache $cache,
  67. LoggerInterface $logger,
  68. $table,
  69. $idField,
  70. $nameField,
  71. callable $normalizationCallback = null,
  72. $wikiId = false
  73. ) {
  74. $this->loadBalancer = $dbLoadBalancer;
  75. $this->cache = $cache;
  76. $this->logger = $logger;
  77. $this->table = $table;
  78. $this->idField = $idField;
  79. $this->nameField = $nameField;
  80. $this->normalizationCallback = $normalizationCallback;
  81. $this->wikiId = $wikiId;
  82. $this->cacheTTL = IExpiringStore::TTL_MONTH;
  83. }
  84. /**
  85. * @param int $index A database index, like DB_MASTER or DB_REPLICA
  86. * @param int $flags Database connection flags
  87. *
  88. * @return IDatabase
  89. */
  90. private function getDBConnection( $index, $flags = 0 ) {
  91. return $this->loadBalancer->getConnection( $index, [], $this->wikiId, $flags );
  92. }
  93. private function getCacheKey() {
  94. return $this->cache->makeKey( 'NameTableSqlStore', $this->table, $this->wikiId );
  95. }
  96. /**
  97. * @param string $name
  98. * @return string
  99. */
  100. private function normalizeName( $name ) {
  101. if ( $this->normalizationCallback === null ) {
  102. return $name;
  103. }
  104. return call_user_func( $this->normalizationCallback, $name );
  105. }
  106. /**
  107. * Acquire the id of the given name.
  108. * This creates a row in the table if it doesn't already exist.
  109. *
  110. * @param string $name
  111. * @throws NameTableAccessException
  112. * @return int
  113. */
  114. public function acquireId( $name ) {
  115. Assert::parameterType( 'string', $name, '$name' );
  116. $name = $this->normalizeName( $name );
  117. $table = $this->getTableFromCachesOrReplica();
  118. $searchResult = array_search( $name, $table, true );
  119. if ( $searchResult === false ) {
  120. $id = $this->store( $name );
  121. if ( $id === null ) {
  122. // RACE: $name was already in the db, probably just inserted, so load from master
  123. // Use DBO_TRX to avoid missing inserts due to other threads or REPEATABLE-READs
  124. $table = $this->loadTable(
  125. $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTOCOMMIT )
  126. );
  127. $searchResult = array_search( $name, $table, true );
  128. if ( $searchResult === false ) {
  129. // Insert failed due to IGNORE flag, but DB_MASTER didn't give us the data
  130. $m = "No insert possible but master didn't give us a record for " .
  131. "'{$name}' in '{$this->table}'";
  132. $this->logger->error( $m );
  133. throw new NameTableAccessException( $m );
  134. }
  135. $this->purgeWANCache(
  136. function () {
  137. $this->cache->reap( $this->getCacheKey(), INF );
  138. }
  139. );
  140. } else {
  141. $table[$id] = $name;
  142. $searchResult = $id;
  143. // As store returned an ID we know we inserted so delete from WAN cache
  144. $this->purgeWANCache(
  145. function () {
  146. $this->cache->delete( $this->getCacheKey() );
  147. }
  148. );
  149. }
  150. $this->tableCache = $table;
  151. }
  152. return $searchResult;
  153. }
  154. /**
  155. * Get the id of the given name.
  156. * If the name doesn't exist this will throw.
  157. * This should be used in cases where we believe the name already exists or want to check for
  158. * existence.
  159. *
  160. * @param string $name
  161. * @throws NameTableAccessException The name does not exist
  162. * @return int Id
  163. */
  164. public function getId( $name ) {
  165. Assert::parameterType( 'string', $name, '$name' );
  166. $name = $this->normalizeName( $name );
  167. $table = $this->getTableFromCachesOrReplica();
  168. $searchResult = array_search( $name, $table, true );
  169. if ( $searchResult !== false ) {
  170. return $searchResult;
  171. }
  172. throw NameTableAccessException::newFromDetails( $this->table, 'name', $name );
  173. }
  174. /**
  175. * Get the name of the given id.
  176. * If the id doesn't exist this will throw.
  177. * This should be used in cases where we believe the id already exists.
  178. *
  179. * Note: Calls to this method will result in a master select for non existing IDs.
  180. *
  181. * @param int $id
  182. * @throws NameTableAccessException The id does not exist
  183. * @return string name
  184. */
  185. public function getName( $id ) {
  186. Assert::parameterType( 'integer', $id, '$id' );
  187. $table = $this->getTableFromCachesOrReplica();
  188. if ( array_key_exists( $id, $table ) ) {
  189. return $table[$id];
  190. }
  191. $table = $this->cache->getWithSetCallback(
  192. $this->getCacheKey(),
  193. $this->cacheTTL,
  194. function ( $oldValue, &$ttl, &$setOpts ) use ( $id ) {
  195. // Check if cached value is up-to-date enough to have $id
  196. if ( is_array( $oldValue ) && array_key_exists( $id, $oldValue ) ) {
  197. // Completely leave the cache key alone
  198. $ttl = WANObjectCache::TTL_UNCACHEABLE;
  199. // Use the old value
  200. return $oldValue;
  201. }
  202. // Regenerate from replica DB, and master DB if needed
  203. foreach ( [ DB_REPLICA, DB_MASTER ] as $source ) {
  204. // Log a fallback to master
  205. if ( $source === DB_MASTER ) {
  206. $this->logger->info(
  207. __METHOD__ . 'falling back to master select from ' .
  208. $this->table . ' with id ' . $id
  209. );
  210. }
  211. $db = $this->getDBConnection( $source );
  212. $cacheSetOpts = Database::getCacheSetOptions( $db );
  213. $table = $this->loadTable( $db );
  214. if ( array_key_exists( $id, $table ) ) {
  215. break; // found it
  216. }
  217. }
  218. // Use the value from last source checked
  219. $setOpts += $cacheSetOpts;
  220. return $table;
  221. },
  222. [ 'minAsOf' => INF ] // force callback run
  223. );
  224. $this->tableCache = $table;
  225. if ( array_key_exists( $id, $table ) ) {
  226. return $table[$id];
  227. }
  228. throw NameTableAccessException::newFromDetails( $this->table, 'id', $id );
  229. }
  230. /**
  231. * Get the whole table, in no particular order as a map of ids to names.
  232. * This method could be subject to DB or cache lag.
  233. *
  234. * @return string[] keys are the name ids, values are the names themselves
  235. * Example: [ 1 => 'foo', 3 => 'bar' ]
  236. */
  237. public function getMap() {
  238. return $this->getTableFromCachesOrReplica();
  239. }
  240. /**
  241. * @return string[]
  242. */
  243. private function getTableFromCachesOrReplica() {
  244. if ( $this->tableCache !== null ) {
  245. return $this->tableCache;
  246. }
  247. $table = $this->cache->getWithSetCallback(
  248. $this->getCacheKey(),
  249. $this->cacheTTL,
  250. function ( $oldValue, &$ttl, &$setOpts ) {
  251. $dbr = $this->getDBConnection( DB_REPLICA );
  252. $setOpts += Database::getCacheSetOptions( $dbr );
  253. return $this->loadTable( $dbr );
  254. }
  255. );
  256. $this->tableCache = $table;
  257. return $table;
  258. }
  259. /**
  260. * Reap the WANCache entry for this table.
  261. *
  262. * @param callable $purgeCallback callback to 'purge' the WAN cache
  263. */
  264. private function purgeWANCache( $purgeCallback ) {
  265. // If the LB has no DB changes don't both with onTransactionPreCommitOrIdle
  266. if ( !$this->loadBalancer->hasOrMadeRecentMasterChanges() ) {
  267. $purgeCallback();
  268. return;
  269. }
  270. $this->getDBConnection( DB_MASTER )
  271. ->onTransactionPreCommitOrIdle( $purgeCallback, __METHOD__ );
  272. }
  273. /**
  274. * Gets the table from the db
  275. *
  276. * @param IDatabase $db
  277. *
  278. * @return string[]
  279. */
  280. private function loadTable( IDatabase $db ) {
  281. $result = $db->select(
  282. $this->table,
  283. [
  284. 'id' => $this->idField,
  285. 'name' => $this->nameField
  286. ],
  287. [],
  288. __METHOD__,
  289. [ 'ORDER BY' => 'id' ]
  290. );
  291. $assocArray = [];
  292. foreach ( $result as $row ) {
  293. $assocArray[$row->id] = $row->name;
  294. }
  295. return $assocArray;
  296. }
  297. /**
  298. * Stores the given name in the DB, returning the ID when an insert occurs.
  299. *
  300. * @param string $name
  301. * @return int|null int if we know the ID, null if we don't
  302. */
  303. private function store( $name ) {
  304. Assert::parameterType( 'string', $name, '$name' );
  305. Assert::parameter( $name !== '', '$name', 'should not be an empty string' );
  306. // Note: this is only called internally so normalization of $name has already occurred.
  307. $dbw = $this->getDBConnection( DB_MASTER );
  308. $dbw->insert(
  309. $this->table,
  310. [ $this->nameField => $name ],
  311. __METHOD__,
  312. [ 'IGNORE' ]
  313. );
  314. if ( $dbw->affectedRows() === 0 ) {
  315. $this->logger->info(
  316. 'Tried to insert name into table ' . $this->table . ', but value already existed.'
  317. );
  318. return null;
  319. }
  320. return $dbw->insertId();
  321. }
  322. }