1<?php
2/**
3 * Advanced generator of database load balancing objects for database farms.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Database
22 */
23
24namespace Wikimedia\Rdbms;
25
26use InvalidArgumentException;
27use LogicException;
28use UnexpectedValueException;
29
30/**
31 * A multi-database, multi-primary DB factory for Wikimedia and similar installations
32 *
33 * @ingroup Database
34 */
35class LBFactoryMulti extends LBFactory {
36	/** @var array<string,LoadBalancer> Map of (main section => tracked LoadBalancer) */
37	private $mainLBs = [];
38	/** @var array<string,LoadBalancer> Map of (external cluster => tracked LoadBalancer) */
39	private $externalLBs = [];
40
41	/** @var string[] Map of (server name => IP address) */
42	private $hostsByServerName;
43	/** @var string[] Map of (database name => main section) */
44	private $sectionsByDB;
45	/** @var int[][][] Map of (main section => group => server name => load ratio) */
46	private $groupLoadsBySection;
47	/** @var int[][][] Map of (database => group => server name => load ratio) */
48	private $groupLoadsByDB;
49	/** @var int[][] Map of (external cluster => server name => load ratio) */
50	private $externalLoadsByCluster;
51	/** @var array Server config map ("host", "serverName", "load", and "groupLoads" ignored) */
52	private $serverTemplate;
53	/** @var array Server config map overriding "serverTemplate" for all external servers */
54	private $externalTemplateOverrides;
55	/** @var array[] Map of (main section => server config map overrides) */
56	private $templateOverridesBySection;
57	/** @var array[] Map of (external cluster => server config map overrides) */
58	private $templateOverridesByCluster;
59	/** @var array Server config override map for all main/external primary DB servers */
60	private $masterTemplateOverrides;
61	/** @var array[] Map of (server name => server config map overrides) for all servers */
62	private $templateOverridesByServer;
63	/** @var string[]|bool[] A map of (main section => read-only message) */
64	private $readOnlyBySection;
65	/** @var array Configuration for the LoadMonitor to use within LoadBalancer instances */
66	private $loadMonitorConfig;
67
68	/**
69	 * Template override precedence (highest => lowest):
70	 *   - templateOverridesByServer
71	 *   - masterTemplateOverrides
72	 *   - templateOverridesBySection/templateOverridesByCluster
73	 *   - externalTemplateOverrides
74	 *   - serverTemplate
75	 * Overrides only work on top level keys (so nested values will not be merged).
76	 *
77	 * Server config maps should be of the format Database::factory() requires.
78	 * Additionally, a 'max lag' key should also be set on server maps, indicating how stale the
79	 * data can be before the load balancer tries to avoid using it. The map can have 'is static'
80	 * set to disable blocking  replication sync checks (intended for archive servers with
81	 * unchanging data).
82	 *
83	 * @see LBFactory::__construct()
84	 * @param array $conf Additional parameters include:
85	 *   - hostsByName: map of (server name => IP address). [optional]
86	 *   - sectionsByDB: map of (database => main section). The database name "DEFAULT" is
87	 *      interpeted as a catch-all for all databases not otherwise mentioned. [optional]
88	 *   - sectionLoads: map of (main section => server name => load ratio); the first host
89	 *      listed in each section is the primary DB server for that section. [optional]
90	 *   - groupLoadsBySection: map of (main section => group => server name => group load ratio).
91	 *      Any ILoadBalancer::GROUP_GENERIC group will be ignored. [optional]
92	 *   - groupLoadsByDB: map of (database => group => server name => load ratio) map. [optional]
93	 *   - externalLoads: map of (cluster => server name => load ratio) map. [optional]
94	 *   - serverTemplate: server config map for Database::factory().
95	 *      Note that "host", "serverName" and "load" entries will be overridden by
96	 *      "groupLoadsBySection" and "hostsByName". [optional]
97	 *   - externalTemplateOverrides: server config map overrides for external stores;
98	 *      respects the override precedence described above. [optional]
99	 *   - templateOverridesBySection: map of (main section => server config map overrides);
100	 *      respects the override precedence described above. [optional]
101	 *   - templateOverridesByCluster: map of (external cluster => server config map overrides);
102	 *      respects the override precedence described above. [optional]
103	 *   - masterTemplateOverrides: server config map overrides for masters;
104	 *      respects the override precedence described above. [optional]
105	 *   - templateOverridesByServer: map of (server name => server config map overrides);
106	 *      respects the override precedence described above and applies to both core
107	 *      and external storage. [optional]
108	 *   - loadMonitor: LoadMonitor::__construct() parameters with "class" field. [optional]
109	 *   - readOnlyBySection: map of (main section => message text or false).
110	 *      String values make sections read only, whereas anything else does not
111	 *      restrict read/write mode. [optional]
112	 */
113	public function __construct( array $conf ) {
114		parent::__construct( $conf );
115
116		$this->hostsByServerName = $conf['hostsByName'] ?? [];
117		$this->sectionsByDB = $conf['sectionsByDB'];
118		$this->groupLoadsBySection = $conf['groupLoadsBySection'] ?? [];
119		foreach ( ( $conf['sectionLoads'] ?? [] ) as $section => $loadsByServerName ) {
120			$this->groupLoadsBySection[$section][ILoadBalancer::GROUP_GENERIC] = $loadsByServerName;
121		}
122		$this->groupLoadsByDB = $conf['groupLoadsByDB'] ?? [];
123		$this->externalLoadsByCluster = $conf['externalLoads'] ?? [];
124		$this->serverTemplate = $conf['serverTemplate'] ?? [];
125		$this->externalTemplateOverrides = $conf['externalTemplateOverrides'] ?? [];
126		$this->templateOverridesBySection = $conf['templateOverridesBySection'] ?? [];
127		$this->templateOverridesByCluster = $conf['templateOverridesByCluster'] ?? [];
128		$this->masterTemplateOverrides = $conf['masterTemplateOverrides'] ?? [];
129		$this->templateOverridesByServer = $conf['templateOverridesByServer'] ?? [];
130		$this->readOnlyBySection = $conf['readOnlyBySection'] ?? [];
131
132		if ( isset( $conf['loadMonitor'] ) ) {
133			$this->loadMonitorConfig = $conf['loadMonitor'];
134		} elseif ( isset( $conf['loadMonitorClass'] ) ) { // b/c
135			$this->loadMonitorConfig = [ 'class' => $conf['loadMonitorClass'] ];
136		} else {
137			$this->loadMonitorConfig = [ 'class' => LoadMonitor::class ];
138		}
139
140		foreach ( array_keys( $this->externalLoadsByCluster ) as $cluster ) {
141			if ( isset( $this->groupLoadsBySection[$cluster] ) ) {
142				throw new LogicException(
143					"External cluster '$cluster' has the same name as a main section/cluster"
144				);
145			}
146		}
147	}
148
149	public function newMainLB( $domain = false, $owner = null ): ILoadBalancer {
150		$domainInstance = $this->resolveDomainInstance( $domain );
151		$database = $domainInstance->getDatabase();
152		$section = $this->getSectionFromDatabase( $database );
153
154		if ( !isset( $this->groupLoadsBySection[$section][ILoadBalancer::GROUP_GENERIC] ) ) {
155			throw new UnexpectedValueException( "Section '$section' has no hosts defined." );
156		}
157
158		$dbGroupLoads = $this->groupLoadsByDB[$database] ?? [];
159		unset( $dbGroupLoads[ILoadBalancer::GROUP_GENERIC] ); // cannot override
160		return $this->newLoadBalancer(
161			$section,
162			array_merge(
163				$this->serverTemplate,
164				$this->templateOverridesBySection[$section] ?? []
165			),
166			array_merge( $this->groupLoadsBySection[$section], $dbGroupLoads ),
167			// Use the LB-specific read-only reason if everything isn't already read-only
168			is_string( $this->readOnlyReason )
169				? $this->readOnlyReason
170				: ( $this->readOnlyBySection[$section] ?? false ),
171			$owner
172		);
173	}
174
175	public function getMainLB( $domain = false ): ILoadBalancer {
176		$domainInstance = $this->resolveDomainInstance( $domain );
177		$section = $this->getSectionFromDatabase( $domainInstance->getDatabase() );
178
179		if ( !isset( $this->mainLBs[$section] ) ) {
180			$this->mainLBs[$section] = $this->newMainLB( $domain, $this->getOwnershipId() );
181		}
182
183		return $this->mainLBs[$section];
184	}
185
186	public function newExternalLB( $cluster, $owner = null ): ILoadBalancer {
187		if ( !isset( $this->externalLoadsByCluster[$cluster] ) ) {
188			throw new InvalidArgumentException( "Unknown cluster '$cluster'" );
189		}
190		return $this->newLoadBalancer(
191			$cluster,
192			array_merge(
193				$this->serverTemplate,
194				$this->externalTemplateOverrides,
195				$this->templateOverridesByCluster[$cluster] ?? []
196			),
197			[ ILoadBalancer::GROUP_GENERIC => $this->externalLoadsByCluster[$cluster] ],
198			$this->readOnlyReason,
199			$owner
200		);
201	}
202
203	public function getExternalLB( $cluster ): ILoadBalancer {
204		if ( !isset( $this->externalLBs[$cluster] ) ) {
205			$this->externalLBs[$cluster] = $this->newExternalLB(
206				$cluster,
207				$this->getOwnershipId()
208			);
209		}
210
211		return $this->externalLBs[$cluster];
212	}
213
214	public function getAllMainLBs(): array {
215		$lbs = [];
216		foreach ( $this->sectionsByDB as $db => $section ) {
217			if ( !isset( $lbs[$section] ) ) {
218				$lbs[$section] = $this->getMainLB( $db );
219			}
220		}
221
222		return $lbs;
223	}
224
225	public function getAllExternalLBs(): array {
226		$lbs = [];
227		foreach ( $this->externalLoadsByCluster as $cluster => $unused ) {
228			$lbs[$cluster] = $this->getExternalLB( $cluster );
229		}
230
231		return $lbs;
232	}
233
234	public function forEachLB( $callback, array $params = [] ) {
235		foreach ( $this->mainLBs as $lb ) {
236			$callback( $lb, ...$params );
237		}
238		foreach ( $this->externalLBs as $lb ) {
239			$callback( $lb, ...$params );
240		}
241	}
242
243	/**
244	 * Make a new load balancer object based on template and load array
245	 *
246	 * @param string $clusterName
247	 * @param array $serverTemplate
248	 * @param array $groupLoads
249	 * @param string|bool $readOnlyReason
250	 * @param int|null $owner
251	 * @return LoadBalancer
252	 */
253	private function newLoadBalancer(
254		string $clusterName,
255		array $serverTemplate,
256		array $groupLoads,
257		$readOnlyReason,
258		$owner
259	) {
260		$lb = new LoadBalancer( array_merge(
261			$this->baseLoadBalancerParams( $owner ),
262			[
263				'servers' => $this->makeServerConfigArrays( $serverTemplate, $groupLoads ),
264				'loadMonitor' => $this->loadMonitorConfig,
265				'readOnlyReason' => $readOnlyReason,
266				'clusterName' => $clusterName
267			]
268		) );
269		$this->initLoadBalancer( $lb );
270
271		return $lb;
272	}
273
274	/**
275	 * Make a server array as expected by LoadBalancer::__construct()
276	 *
277	 * @param array $serverTemplate Server config map
278	 * @param int[][] $groupLoads Map of (group => server name => load)
279	 * @return array[] List of server config maps
280	 */
281	private function makeServerConfigArrays( array $serverTemplate, array $groupLoads ) {
282		// The primary DB server is the first host explicitly listed in the generic load group
283		if ( !$groupLoads[ILoadBalancer::GROUP_GENERIC] ) {
284			throw new UnexpectedValueException( "Empty generic load array; no primary DB defined." );
285		}
286		$groupLoadsByServerName = $this->reindexGroupLoadsByServerName( $groupLoads );
287		// Get the ordered map of (server name => load); the primary DB server is first
288		$genericLoads = $groupLoads[ILoadBalancer::GROUP_GENERIC];
289		// Implictly append any hosts that only appear in custom load groups
290		$genericLoads += array_fill_keys( array_keys( $groupLoadsByServerName ), 0 );
291		$servers = [];
292		foreach ( $genericLoads as $serverName => $load ) {
293			$servers[] = array_merge(
294				$serverTemplate,
295				$servers ? [] : $this->masterTemplateOverrides,
296				$this->templateOverridesByServer[$serverName] ?? [],
297				[
298					'host' => $this->hostsByServerName[$serverName] ?? $serverName,
299					'serverName' => $serverName,
300					'load' => $load,
301					'groupLoads' => $groupLoadsByServerName[$serverName] ?? []
302				]
303			);
304		}
305
306		return $servers;
307	}
308
309	/**
310	 * Take a group load array indexed by (group,server) and reindex it by (server,group)
311	 *
312	 * @param int[][] $groupLoads Map of (group => server name => load)
313	 * @return int[][] Map of (server name => group => load)
314	 */
315	private function reindexGroupLoadsByServerName( array $groupLoads ) {
316		$groupLoadsByServerName = [];
317		foreach ( $groupLoads as $group => $loadByServerName ) {
318			foreach ( $loadByServerName as $serverName => $load ) {
319				$groupLoadsByServerName[$serverName][$group] = $load;
320			}
321		}
322
323		return $groupLoadsByServerName;
324	}
325
326	/**
327	 * @param string $database
328	 * @return string Section name
329	 */
330	private function getSectionFromDatabase( $database ) {
331		return $this->sectionsByDB[$database] ?? self::CLUSTER_MAIN_DEFAULT;
332	}
333}
334