Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
52 / 56
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MultiClusterAssignment
92.86% covered (success)
92.86%
52 / 56
81.82% covered (warning)
81.82%
9 / 11
27.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 evalGroupStrategy
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 initClusters
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 uniqueId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWritableClusters
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getAllKnownClusters
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 canWriteToCluster
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCluster
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSearchCluster
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCrossClusterName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getServerList
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
1<?php
2
3namespace CirrusSearch\Assignment;
4
5use CirrusSearch\SearchConfig;
6use Wikimedia\Assert\Assert;
7
8class MultiClusterAssignment implements ClusterAssignment {
9    /** @var SearchConfig */
10    private $config;
11    /** @var array[][]|null 2d array mapping (replica, group) to connection configuration */
12    private $clusters;
13    /** @var string */
14    private $group;
15
16    public function __construct( SearchConfig $config ) {
17        $this->config = $config;
18        $groupConfig = $config->get( 'CirrusSearchReplicaGroup' );
19        if ( $groupConfig === null ) {
20            throw new \RuntimeException( 'CirrusSearchReplicaGroup is null' );
21        }
22        if ( is_string( $groupConfig ) ) {
23            $groupConfig = [
24                'type' => 'constant',
25                'group' => $groupConfig,
26            ];
27        }
28        $this->group = $this->evalGroupStrategy( $groupConfig );
29    }
30
31    /**
32     * @param array $groupConfig
33     * @return string
34     */
35    private function evalGroupStrategy( array $groupConfig ) {
36        // Determine which group this wiki belongs to
37        switch ( $groupConfig['type'] ) {
38            case 'constant':
39                return $groupConfig['group'];
40            case 'roundrobin':
41                $wikiId = $this->config->getWikiId();
42                $mod = count( $groupConfig['groups'] );
43                Assert::precondition( $mod > 0, "At least one replica group must be defined for roundrobin" );
44                $idx = crc32( $wikiId ) % $mod;
45                return $groupConfig['groups'][$idx];
46            default:
47                throw new \RuntimeException( "Unknown replica group type: {$groupConfig['type']}" );
48        }
49    }
50
51    private function initClusters(): array {
52        $clusters = [];
53        // We could require the input come in this shape, instead of reshaping
54        // it when we start, but it seemed awkward to work with.
55        foreach ( $this->config->get( 'CirrusSearchClusters' ) as $name => $config ) {
56            $replica = $config['replica'] ?? $name;
57            // Tempting to skip everything that doesn't match $this->group, but we have
58            // to also track single group replicas with arbitrary group names.
59            $group = $config['group'] ?? 'default';
60            unset( $config['replica'], $config['group'] );
61            if ( isset( $clusters[$replica][$group] ) ) {
62                throw new \RuntimeException( "Multiple clusters for replica: $replica group: $group" );
63            }
64            $clusters[$replica][$group] = $config;
65        }
66        return $clusters;
67    }
68
69    /**
70     * @param string $cluster Name of requested cluster
71     * @return string Uniquely identifies the connection properties.
72     */
73    public function uniqueId( $cluster ) {
74        return "{$this->group}:$cluster";
75    }
76
77    /**
78     * @param string $updateGroup UpdateGroup::* constant
79     * @return string[] List of CirrusSearch cluster names to write to.
80     */
81    public function getWritableClusters( string $updateGroup ): array {
82        $clusters = $this->config->get( 'CirrusSearchWriteClusters' );
83        if ( $clusters === null ) {
84            // No explicitly configured set of write clusters. Write to all known replicas.
85            return $this->getAllKnownClusters();
86        }
87        if ( count( $clusters ) === 0 || isset( $clusters[0] ) ) {
88            // Simple list of writable clusters
89            return $clusters;
90        }
91        // Writable clusters defined per update group
92        return $clusters[$updateGroup] ?? $clusters['default'];
93    }
94
95    public function getAllKnownClusters(): array {
96        if ( $this->clusters === null ) {
97            $this->clusters = $this->initClusters();
98        }
99        return array_keys( $this->clusters );
100    }
101
102    /**
103     * Check if a cluster is configured to accept writes
104     *
105     * @param string $cluster
106     * @param string $updateGroup UpdateGroup::* constant
107     * @return bool
108     */
109    public function canWriteToCluster( $cluster, $updateGroup ) {
110        return in_array( $cluster, $this->getWritableClusters( $updateGroup ) );
111    }
112
113    /**
114     * Check if a cluster is defined
115     *
116     * @param string $cluster
117     * @return bool
118     */
119    public function hasCluster( string $cluster ): bool {
120        if ( $this->clusters === null ) {
121            $this->clusters = $this->initClusters();
122        }
123        return isset( $this->clusters[$cluster] );
124    }
125
126    /**
127     * @return string Name of the default search cluster.
128     */
129    public function getSearchCluster() {
130        return $this->config->get( 'CirrusSearchDefaultCluster' );
131    }
132
133    /**
134     * @return string Name to prefix indices with when
135     *  using cross-cluster-search.
136     */
137    public function getCrossClusterName() {
138        return $this->group;
139    }
140
141    /**
142     * @param string|null $replica
143     * @return string[]|array[]
144     */
145    public function getServerList( $replica = null ): array {
146        if ( $this->clusters === null ) {
147            $this->clusters = $this->initClusters();
148        }
149        $replica ??= $this->config->get( 'CirrusSearchDefaultCluster' );
150        if ( !isset( $this->clusters[$replica] ) ) {
151            $available = implode( ',', array_keys( $this->clusters ) );
152            throw new \RuntimeException( "Missing replica <$replica>, have <$available>" );
153        } elseif ( isset( $this->clusters[$replica][$this->group] ) ) {
154            return $this->clusters[$replica][$this->group];
155        } elseif ( count( $this->clusters[$replica] ) === 1 ) {
156            // If a replica only has a single elasticsearch cluster then by
157            // definition everything goes there.
158            return reset( $this->clusters[$replica] );
159        } else {
160            throw new \RuntimeException( "Missing replica: $replica group: {$this->group}" );
161        }
162    }
163}