MDL-68729 Search: Allow query on one server while indexing another
authorsam marshall <s.marshall@open.ac.uk>
Fri, 15 May 2020 16:20:28 +0000 (17:20 +0100)
committersam marshall <s.marshall@open.ac.uk>
Thu, 6 Aug 2020 11:12:01 +0000 (12:12 +0100)
To support transitions from one search engine to a different one, or
to a different installation of the same kind, this feature allows for
queries to use a different search engine from indexing. So you can
reindex (and do all other search operation) on one server, while
user queries are unaffected on a different server.

This feature supports changing between search engine types, and also
between two Solr installations.

12 files changed:
admin/settings/plugins.php
lang/en/admin.php
search/classes/engine.php
search/classes/manager.php
search/engine/solr/classes/engine.php
search/engine/solr/classes/schema.php
search/engine/solr/settings.php
search/engine/solr/tests/engine_test.php
search/index.php
search/tests/behat/search_information.feature [new file with mode: 0644]
search/tests/fixtures/testable_core_search.php
search/upgrade.txt

index 1942627..cc23ff3 100644 (file)
@@ -549,8 +549,19 @@ if ($hassiteconfig) {
 
     // Search engine selection.
     $temp->add(new admin_setting_heading('searchengineheading', new lang_string('searchengine', 'admin'), ''));
-    $temp->add(new admin_setting_configselect('searchengine',
-                                new lang_string('selectsearchengine', 'admin'), '', 'simpledb', $engines));
+    $searchengineselect = new admin_setting_configselect('searchengine',
+            new lang_string('selectsearchengine', 'admin'), '', 'simpledb', $engines);
+    $searchengineselect->set_validate_function(function(string $value): string {
+        global $CFG;
+
+        // Check nobody's setting the indexing and query-only server to the same one.
+        if ($CFG->searchenginequeryonly === $value) {
+            return get_string('searchenginequeryonlysame', 'admin');
+        } else {
+            return '';
+        }
+    });
+    $temp->add($searchengineselect);
     $temp->add(new admin_setting_heading('searchoptionsheading', new lang_string('searchoptions', 'admin'), ''));
     $temp->add(new admin_setting_configcheckbox('searchindexwhendisabled',
             new lang_string('searchindexwhendisabled', 'admin'), new lang_string('searchindexwhendisabled_desc', 'admin'),
@@ -590,6 +601,43 @@ if ($hassiteconfig) {
         new lang_string('searchhideallcategory_desc', 'admin'),
         0));
 
+    $temp->add(new admin_setting_heading('searchmanagement', new lang_string('searchmanagement', 'admin'),
+            new lang_string('searchmanagement_desc', 'admin')));
+
+    // Get list of search engines including those with alternate settings.
+    $searchenginequeryonlyselect = new admin_setting_configselect('searchenginequeryonly',
+            new lang_string('searchenginequeryonly', 'admin'),
+            new lang_string('searchenginequeryonly_desc', 'admin'), '', function() use($engines) {
+                $options = ['' => new lang_string('searchenginequeryonly_none', 'admin')];
+                foreach ($engines as $name => $display) {
+                    $options[$name] = $display;
+
+                    $classname = '\search_' . $name . '\engine';
+                    $engine = new $classname;
+                    if ($engine->has_alternate_configuration()) {
+                        $options[$name . '-alternate'] =
+                                new lang_string('searchenginealternatesettings', 'admin', $display);
+                    }
+                }
+                return $options;
+            });
+    $searchenginequeryonlyselect->set_validate_function(function(string $value): string {
+        global $CFG;
+
+        // Check nobody's setting the indexing and query-only server to the same one.
+        if ($CFG->searchengine === $value) {
+            return get_string('searchenginequeryonlysame', 'admin');
+        } else {
+            return '';
+        }
+    });
+    $temp->add($searchenginequeryonlyselect);
+    $temp->add(new admin_setting_configcheckbox('searchbannerenable',
+            new lang_string('searchbannerenable', 'admin'), new lang_string('searchbannerenable_desc', 'admin'),
+            0));
+    $temp->add(new admin_setting_confightmleditor('searchbanner',
+            new lang_string('searchbanner', 'admin'), '', ''));
+
     $ADMIN->add('searchplugins', $temp);
     $ADMIN->add('searchplugins', new admin_externalpage('searchareas', new lang_string('searchareas', 'admin'),
         new moodle_url('/admin/searchareas.php')));
index 2571bca..7fff4c0 100644 (file)
@@ -1107,6 +1107,8 @@ $string['searchallavailablecourses'] = 'Searchable courses';
 $string['searchallavailablecourses_off'] = 'Search within enrolled courses only';
 $string['searchallavailablecourses_on'] = 'Search within all courses the user can access';
 $string['searchallavailablecourses_desc'] = 'In some situations the search engine may not work when searching across a large number of courses. Set to search only enrolled courses if you need to restrict the number of courses searched.';
+$string['searchalternatesettings'] = 'Query-only alternate settings';
+$string['searchalternatesettings_desc'] = 'If you complete these settings, you can select \'alternate settings\' for this search engine in the query-only search engine option on the \'Manage global search\' page. This is only useful when moving between two search engines of the same type.';
 $string['searchdisplay'] = 'Search results display options';
 $string['searchenablecategories'] = 'Display results in separate categories';
 $string['searchenablecategories_desc'] = 'If enabled, search results will be displayed in separate categories.';
@@ -1118,10 +1120,18 @@ $string['searchallavailablecoursesdesc'] = 'If set to search within enrolled cou
 $string['searchincludeallcourses'] = 'Include all visible courses';
 $string['searchincludeallcourses_desc'] = 'If enabled, search results will include course information (name and summary) of courses which are visible to the user, even if they don\'t have access to the course content.';
 $string['searchalldeleted'] = 'All indexed contents have been deleted';
+$string['searchbannerenable'] = 'Display search information';
+$string['searchbannerenable_desc'] = 'If enabled, the below text will display at the top of the search screen for all users. This can be used to keep users informed while maintenance is being carried out on the search system.';
+$string['searchbanner'] = 'Search information';
 $string['searchareaenabled'] = 'Search area enabled';
 $string['searchareadisabled'] = 'Search area disabled';
 $string['searchdeleteindex'] = 'Delete all indexed contents';
 $string['searchengine'] = 'Search engine';
+$string['searchenginealternatesettings'] = '{$a} (alternate settings)';
+$string['searchenginequeryonly'] = 'Query-only search engine';
+$string['searchenginequeryonly_desc'] = 'This search engine will be used only for making queries, not indexing. By using this feature you can reindex in a different search engine, while user queries continue to work from this one.';
+$string['searchenginequeryonly_none'] = 'None (use main search engine for queries)';
+$string['searchenginequeryonlysame'] = 'The query-only search engine and the main search engine cannot be set to the same value.';
 $string['searchindexactions'] = 'Index actions';
 $string['searchindexdeleted'] = 'Index deleted';
 $string['searchindextime'] = 'Indexing time limit';
@@ -1131,6 +1141,8 @@ $string['searchindexwhendisabled'] = 'Index when disabled';
 $string['searchindexwhendisabled_desc'] = 'Allows the scheduled task to build the search index even when search is disabled. This is useful if you want to build the index before the search facility appears to students.';
 $string['searchinsettings'] = 'Search in settings';
 $string['searchlastrun'] = 'Last run (time, # docs, # records, # ignores)';
+$string['searchmanagement'] = 'Search management';
+$string['searchmanagement_desc'] = 'These options are useful when making changes on sites with very large search indexes that take a long time to rebuild.';
 $string['searchnotavailable'] = 'Search is not available';
 $string['searchpartial'] = '(not yet fully indexed)';
 $string['searchoptions'] = 'Search options';
index 5854c02..722e0fb 100644 (file)
@@ -84,9 +84,13 @@ abstract class engine {
      *
      * Search engine availability should be checked separately.
      *
+     * The alternate configuration option is only used to construct a special second copy of the
+     * search engine object, as described in {@see has_alternate_configuration}.
+     *
+     * @param bool $alternateconfiguration If true, use alternate configuration settings
      * @return void
      */
-    public function __construct() {
+    public function __construct(bool $alternateconfiguration = false) {
 
         $classname = get_class($this);
         if (strpos($classname, '\\') === false) {
@@ -102,6 +106,19 @@ abstract class engine {
         } else {
             $this->config = new stdClass();
         }
+
+        // For alternate configuration, automatically replace normal configuration values with
+        // those beginning with 'alternate'.
+        if ($alternateconfiguration) {
+            foreach ((array)$this->config as $key => $value) {
+                if (preg_match('~^alternate(.*)$~', $key, $matches)) {
+                    $this->config->{$matches[1]} = $value;
+                }
+            }
+        }
+
+        // Flag just in case engine needs to know it is using the alternate configuration.
+        $this->config->alternateconfiguration = $alternateconfiguration;
     }
 
     /**
@@ -740,4 +757,24 @@ abstract class engine {
     public function get_batch_max_content(): int {
         return 1024 * 1024;
     }
+
+    /**
+     * Checks if the search engine has an alternate configuration.
+     *
+     * This is used where the same search engine class supports two different configurations,
+     * which are both shown on the settings screen. The alternate configuration is selected by
+     * passing 'true' parameter to the constructor.
+     *
+     * The feature is used when a different connection is in use for indexing vs. querying
+     * the search engine.
+     *
+     * This function should only return true if the engine supports an alternate configuration
+     * and the user has filled in the settings. (We do not need to test they are valid, that will
+     * happen as normal.)
+     *
+     * @return bool True if an alternate configuration is defined
+     */
+    public function has_alternate_configuration(): bool {
+        return false;
+    }
 }
index 458dad7..8a76862 100644 (file)
@@ -190,13 +190,18 @@ class manager {
      * parameter provides a way to skip those checks on pages which are used frequently. It has
      * no effect if an instance has already been constructed in this request.
      *
+     * The $query parameter indicates that the page is used for queries rather than indexing. If
+     * configured, this will cause the query-only search engine to be used instead of the 'normal'
+     * one.
+     *
      * @see \core_search\engine::is_installed
      * @see \core_search\engine::is_server_ready
      * @param bool $fast Set to true when calling on a page that requires high performance
+     * @param bool $query Set true on a page that is used for querying
      * @throws \core_search\engine_exception
      * @return \core_search\manager
      */
-    public static function instance($fast = false) {
+    public static function instance(bool $fast = false, bool $query = false) {
         global $CFG;
 
         // One per request, this should be purged during testing.
@@ -208,7 +213,7 @@ class manager {
             throw new \core_search\engine_exception('enginenotselected', 'search');
         }
 
-        if (!$engine = static::search_engine_instance()) {
+        if (!$engine = static::search_engine_instance($query)) {
             throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine);
         }
 
@@ -287,17 +292,46 @@ class manager {
     /**
      * Returns an instance of the search engine.
      *
+     * @param bool $query If true, gets the query-only search engine (where configured)
      * @return \core_search\engine
      */
-    public static function search_engine_instance() {
+    public static function search_engine_instance(bool $query = false) {
         global $CFG;
 
-        $classname = '\\search_' . $CFG->searchengine . '\\engine';
+        if ($query && $CFG->searchenginequeryonly) {
+            return self::search_engine_instance_from_setting($CFG->searchenginequeryonly);
+        } else {
+            return self::search_engine_instance_from_setting($CFG->searchengine);
+        }
+    }
+
+    /**
+     * Loads a search engine based on the name given in settings, which can optionally
+     * include '-alternate' to indicate that an alternate version should be used.
+     *
+     * @param string $setting
+     * @return engine|null
+     */
+    protected static function search_engine_instance_from_setting(string $setting): ?engine {
+        if (preg_match('~^(.*)-alternate$~', $setting, $matches)) {
+            $enginename = $matches[1];
+            $alternate = true;
+        } else {
+            $enginename = $setting;
+            $alternate = false;
+        }
+
+        $classname = '\\search_' . $enginename . '\\engine';
         if (!class_exists($classname)) {
-            return false;
+            return null;
         }
 
-        return new $classname();
+        if ($alternate) {
+            return new $classname(true);
+        } else {
+            // Use the constructor with no parameters for compatibility.
+            return new $classname();
+        }
     }
 
     /**
index 0d433ab..68912be 100644 (file)
@@ -116,10 +116,11 @@ class engine extends \core_search\engine {
     /**
      * Initialises the search engine configuration.
      *
+     * @param bool $alternateconfiguration If true, use alternate configuration settings
      * @return void
      */
-    public function __construct() {
-        parent::__construct();
+    public function __construct(bool $alternateconfiguration = false) {
+        parent::__construct($alternateconfiguration);
 
         $curlversion = curl_version();
         if (isset($curlversion['version']) && stripos($curlversion['version'], '7.35.') === 0) {
@@ -1256,7 +1257,7 @@ class engine extends \core_search\engine {
 
         // Check that the schema is already set up.
         try {
-            $schema = new \search_solr\schema();
+            $schema = new schema($this);
             $schema->validate_setup();
         } catch (\moodle_exception $e) {
             return $e->getMessage();
@@ -1466,7 +1467,7 @@ class engine extends \core_search\engine {
 
     protected function update_schema($oldversion, $newversion) {
         // Construct schema.
-        $schema = new schema();
+        $schema = new schema($this);
         $cansetup = $schema->can_setup_server();
         if ($cansetup !== true) {
             return $cansetup;
@@ -1564,4 +1565,15 @@ class engine extends \core_search\engine {
             throw new \core_search\engine_exception('error_solr', 'search_solr', '', $e->getMessage());
         }
     }
+
+    /**
+     * Checks if an alternate configuration has been defined.
+     *
+     * @return bool True if alternate configuration is available
+     */
+    public function has_alternate_configuration(): bool {
+        return !empty($this->config->alternateserver_hostname) &&
+                !empty($this->config->alternateindexname) &&
+                !empty($this->config->alternateserver_port);
+    }
 }
index a62d4b9..743166f 100644 (file)
@@ -60,10 +60,11 @@ class schema {
     /**
      * Constructor.
      *
+     * @param engine $engine Optional engine parameter, if not specified then one will be created
      * @throws \moodle_exception
      * @return void
      */
-    public function __construct() {
+    public function __construct(engine $engine = null) {
         if (!$this->config = get_config('search_solr')) {
             throw new \moodle_exception('missingconfig', 'search_solr');
         }
@@ -72,7 +73,7 @@ class schema {
             throw new \moodle_exception('missingconfig', 'search_solr');
         }
 
-        $this->engine = new engine();
+        $this->engine = $engine ?? new engine();
         $this->curl = $this->engine->get_curl_object();
 
         // HTTP headers.
index f09e758..fd566dc 100644 (file)
@@ -57,6 +57,42 @@ if ($ADMIN->fulltree) {
             $settings->add(new admin_setting_configtext('search_solr/maxindexfilekb',
                     new lang_string('maxindexfilekb', 'search_solr'),
                     new lang_string('maxindexfilekb_help', 'search_solr'), '2097152', PARAM_INT));
+
+            // Alternate connection.
+            $settings->add(new admin_setting_heading('search_solr_alternatesettings',
+                    new lang_string('searchalternatesettings', 'admin'),
+                    new lang_string('searchalternatesettings_desc', 'admin')));
+            $settings->add(new admin_setting_configtext('search_solr/alternateserver_hostname',
+                    new lang_string('solrserverhostname', 'search_solr'),
+                    new lang_string('solrserverhostname_desc', 'search_solr'), '127.0.0.1', PARAM_HOST));
+            $settings->add(new admin_setting_configtext('search_solr/alternateindexname',
+                    new lang_string('solrindexname', 'search_solr'), '', '', PARAM_ALPHANUMEXT));
+            $settings->add(new admin_setting_configcheckbox('search_solr/alternatesecure',
+                    new lang_string('solrsecuremode', 'search_solr'), '', 0, 1, 0));
+
+            $secure = get_config('search_solr', 'alternatesecure');
+            $defaultport = !empty($secure) ? 8443 : 8983;
+            $settings->add(new admin_setting_configtext('search_solr/alternateserver_port',
+                    new lang_string('solrhttpconnectionport', 'search_solr'), '', $defaultport, PARAM_INT));
+            $settings->add(new admin_setting_configtext('search_solr/alternateserver_username',
+                    new lang_string('solrauthuser', 'search_solr'), '', '', PARAM_RAW));
+            $settings->add(new admin_setting_configpasswordunmask('search_solr/alternateserver_password',
+                    new lang_string('solrauthpassword', 'search_solr'), '', ''));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_cert',
+                    new lang_string('solrsslcert', 'search_solr'),
+                    new lang_string('solrsslcert_desc', 'search_solr'), '', PARAM_RAW));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_key',
+                    new lang_string('solrsslkey', 'search_solr'),
+                    new lang_string('solrsslkey_desc', 'search_solr'), '', PARAM_RAW));
+            $settings->add(new admin_setting_configpasswordunmask('search_solr/alternatessl_keypassword',
+                    new lang_string('solrsslkeypassword', 'search_solr'),
+                    new lang_string('solrsslkeypassword_desc', 'search_solr'), ''));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_cainfo',
+                    new lang_string('solrsslcainfo', 'search_solr'),
+                    new lang_string('solrsslcainfo_desc', 'search_solr'), '', PARAM_RAW));
+            $settings->add(new admin_setting_configtext('search_solr/alternatessl_capath',
+                    new lang_string('solrsslcapath', 'search_solr'),
+                    new lang_string('solrsslcapath_desc', 'search_solr'), '', PARAM_RAW));
         }
     }
 }
index 0bdae77..0acc303 100644 (file)
@@ -132,7 +132,7 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->search->delete_index();
 
         // Add moodle fields if they don't exist.
-        $schema = new \search_solr\schema();
+        $schema = new \search_solr\schema($this->engine);
         $schema->setup(false);
     }
 
@@ -159,6 +159,45 @@ class search_solr_engine_testcase extends advanced_testcase {
         $this->assertTrue($this->engine->is_server_ready());
     }
 
+    /**
+     * Tests that the alternate settings are used when configured.
+     */
+    public function test_alternate_settings() {
+        // Index a couple of things.
+        $this->generator->create_record();
+        $this->generator->create_record();
+        $this->search->index();
+
+        // By default settings, alternates are not set.
+        $this->assertFalse($this->engine->has_alternate_configuration());
+
+        // Set up all the config the same as normal.
+        foreach (['server_hostname', 'indexname', 'secure', 'server_port',
+                'server_username', 'server_password'] as $setting) {
+            set_config('alternate' . $setting, get_config('search_solr', $setting), 'search_solr');
+        }
+        // Also mess up the normal config.
+        set_config('indexname', 'not_the_right_index_name', 'search_solr');
+
+        // Construct a new engine using normal settings.
+        $engine = new search_solr\engine();
+
+        // Now alternates are available.
+        $this->assertTrue($engine->has_alternate_configuration());
+
+        // But it won't actually work because of the bogus index name.
+        $this->assertFalse($engine->is_server_ready() === true);
+        $this->assertDebuggingCalled();
+
+        // But if we construct one using alternate settings, it will work as normal.
+        $engine = new search_solr\engine(true);
+        $this->assertTrue($engine->is_server_ready());
+
+        // Including finding the search results.
+        $this->assertCount(2, $engine->execute_query(
+                (object)['q' => 'message'], (object)['everything' => true]));
+    }
+
     /**
      * @dataProvider file_indexing_provider
      */
index 936dfbc..c074716 100644 (file)
@@ -64,7 +64,7 @@ if (\core_search\manager::is_global_search_enabled() === false) {
     exit;
 }
 
-$search = \core_search\manager::instance(true);
+$search = \core_search\manager::instance(true, true);
 
 // Set up custom data for form.
 $customdata = ['searchengine' => $search->get_engine()->get_plugin_name()];
@@ -176,6 +176,11 @@ if ($data) {
     $results = $search->paged_search($data, $page);
 }
 
+// Show search information if configured by system administrator.
+if ($CFG->searchbannerenable && $CFG->searchbanner) {
+    echo $OUTPUT->notification(format_text($CFG->searchbanner, FORMAT_HTML), 'notifywarning');
+}
+
 if ($errorstr = $search->get_engine()->get_query_error()) {
     echo $OUTPUT->notification(get_string('queryerror', 'search', $errorstr), 'notifyproblem');
 } else if (empty($results->totalcount) && !empty($data)) {
diff --git a/search/tests/behat/search_information.feature b/search/tests/behat/search_information.feature
new file mode 100644 (file)
index 0000000..5ba7e02
--- /dev/null
@@ -0,0 +1,36 @@
+@core @core_search
+Feature: Show system information in the search interface
+  In order to let users know if there are current problems with search
+  As an admin
+  I need to be able to show information on search pages
+
+  Background:
+    Given the following config values are set as admin:
+      | enableglobalsearch | 1        |
+      | searchengine       | simpledb |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Information displays when enabled
+    When the following config values are set as admin:
+      | searchbannerenable | 1                                                                             |
+      | searchbanner       | The search currently only finds frog-related content; we hope to fix it soon. |
+    And I search for "toads" using the header global search box
+    Then I should see "The search currently only finds frog-related content" in the ".notifywarning" "css_element"
+
+  @javascript
+  Scenario: Information does not display when not enabled
+    When the following config values are set as admin:
+      | searchbannerenable | 0                                                                             |
+      | searchbanner       | The search currently only finds frog-related content; we hope to fix it soon. |
+    And I search for "toads" using the header global search box
+    Then I should not see "The search currently only finds frog-related content"
+    And ".notifywarning" "css_element" should not exist
+
+  @javascript
+  Scenario: Information does not display when left blank
+    When the following config values are set as admin:
+      | searchbannerenable | 1 |
+      | searchbanner       |   |
+    And I search for "toads" using the header global search box
+    Then ".notifywarning" "css_element" should not exist
index 62193b4..d53b0c6 100644 (file)
@@ -46,9 +46,10 @@ class testable_core_search extends \core_search\manager {
      * Auto enables global search.
      *
      * @param  \core_search\engine|bool $searchengine
+     * @param bool $ignored Second param just to make this compatible with base class
      * @return testable_core_search
      */
-    public static function instance($searchengine = false) {
+    public static function instance($searchengine = false, bool $ignored = false) {
 
         // One per request, this should be purged during testing.
         if (self::$instance !== null) {
index 3566bc8..d56cdd9 100644 (file)
@@ -8,6 +8,11 @@ information provided here is intended especially for developers.
   should implement add_document_batch() function and return true to supports_add_document_batch().
   There is also an additional parameter returned from add_documents() with the number of batches
   sent, which is used for the log display. Existing engines should continue to work unmodified.
+* Search engines can now implement the optional has_alternate_configuration() function to indicate
+  if they provide two different connection configurations (for use when moving between two search
+  engines of the same type). The constructor should also accept a boolean value (true = alternate);
+  passing this to the base class constructor will automatically switch in the alternate
+  configuration settings, provided they begin with 'alternate'.
 
 === 3.8 ===