From 5d9baf17cfc4ab0bf8555cfe4581d7a218c4a1a9 Mon Sep 17 00:00:00 2001 From: Nick Clemens Date: Wed, 5 Aug 2020 14:20:47 +0200 Subject: [PATCH] Bug 25265: Prevent double reindex of the same item in batchmod When batch editing, 2 reindex calls are sent to ES/Zebra. We can easily avoid that reusing the skip_modzebra_update Additionally we should only send one request for biblio, and we should only do it if we succeed As the whole batch mod is in a transaction it is possible to fail in which case Zebra queue is reset, but ES indexes have already been set In addition to the skip param this patchset moves Zebra and Elasticsearch calls to Indexer modules and introduces a generic Koha::SearchEngine::Indexer so that we don't need to check the engine when calling for index The new index_records routine takes an array so that we can reduce the calls to the ES server. The index_records routine for Zebra loops over ModZebra to avoid affecting current behaviour Test plan: General tests, under both search engines: 1 - Add a biblio and confirm it is searchable 2 - Edit the biblio and confirm changes are searchable 3 - Add an item, confirm it is searchable 4 - Delete an item, confirm it is not searchable 5 - Delete a biblio, confirm it is not searchable 6 - Add an authority and confirm it is searchable 7 - Delete an authority and confirm it is not searchable Batch mod tests, under both search engines 1 - Have a bib with several items, none marked 'not for loan' 2 - Do a staff search that returns this biblio 3 - Items show as available 4 - Click on title to go to details page 5 - Edit->Item in a batch 6 - Set the not for loan status for all items 7 - Repeat your search 8 - Items show as not for loan --- C4/AuthoritiesMarc.pm | 7 +++- C4/Biblio.pm | 41 ++++--------------- C4/Circulation.pm | 18 ++++++++- C4/Items.pm | 31 ++++++++++---- Koha/Item.pm | 13 +++--- Koha/SearchEngine/Elasticsearch/Indexer.pm | 37 +++++++++++++++++ Koha/SearchEngine/Indexer.pm | 59 +++++++++++++++++++++++++++ Koha/SearchEngine/Zebra/Indexer.pm | 65 ++++++++++++++++++++++++++++++ cataloguing/additem.pl | 7 +++- tools/batchMod.pl | 21 ++++++++-- 10 files changed, 242 insertions(+), 57 deletions(-) create mode 100644 Koha/SearchEngine/Indexer.pm create mode 100644 Koha/SearchEngine/Zebra/Indexer.pm diff --git a/C4/AuthoritiesMarc.pm b/C4/AuthoritiesMarc.pm index bd25837c36..cbf59cdc03 100644 --- a/C4/AuthoritiesMarc.pm +++ b/C4/AuthoritiesMarc.pm @@ -34,6 +34,7 @@ use Koha::Authority::Types; use Koha::Authority; use Koha::Libraries; use Koha::SearchEngine; +use Koha::SearchEngine::Indexer; use Koha::SearchEngine::Search; use vars qw(@ISA @EXPORT); @@ -628,7 +629,8 @@ sub AddAuthority { $record->insert_fields_ordered( MARC::Field->new( '001', $authid ) ); # Update $dbh->do( "UPDATE auth_header SET authtypecode=?, marc=?, marcxml=? WHERE authid=?", undef, $authtypecode, $record->as_usmarc, $record->as_xml_record($format), $authid ) or die $DBI::errstr; - ModZebra( $authid, 'specialUpdate', 'authorityserver', $record ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::AUTHORITIES_INDEX }); + $indexer->index_records( $authid, "specialUpdate", "authorityserver", $record ); return ( $authid ); } @@ -656,7 +658,8 @@ sub DelAuthority { merge({ mergefrom => $authid }) if !$skip_merge; $dbh->do( "DELETE FROM auth_header WHERE authid=?", undef, $authid ); logaction( "AUTHORITIES", "DELETE", $authid, "authority" ) if C4::Context->preference("AuthoritiesLog"); - ModZebra( $authid, "recordDelete", "authorityserver", undef); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::AUTHORITIES_INDEX }); + $indexer->index_records( $authid, "recordDelete", "authorityserver", undef ); } =head2 ModAuthority diff --git a/C4/Biblio.pm b/C4/Biblio.pm index 97fa95aeb1..090ac1ef5a 100644 --- a/C4/Biblio.pm +++ b/C4/Biblio.pm @@ -103,6 +103,7 @@ use Koha::Holds; use Koha::ItemTypes; use Koha::Plugins; use Koha::SearchEngine; +use Koha::SearchEngine::Indexer; use Koha::Libraries; use Koha::Util::MARC; @@ -395,7 +396,8 @@ sub DelBiblio { # for at least 2 reasons : # - if something goes wrong, the biblio may be deleted from Koha but not from zebra # and we would have no way to remove it (except manually in zebra, but I bet it would be very hard to handle the problem) - ModZebra( $biblionumber, "recordDelete", "biblioserver" ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $biblionumber, "recordDelete", "biblioserver" ); # delete biblioitems and items from Koha tables and save in deletedbiblioitems,deleteditems $sth = $dbh->prepare("SELECT biblioitemnumber FROM biblioitems WHERE biblionumber=?"); @@ -2497,42 +2499,12 @@ $op is specialUpdate or recordDelete, and is used to know what we want to do $server is the server that we want to update -$record is the updated MARC record. If it's not supplied -and is needed it will be loaded from the database. - =cut sub ModZebra { ###Accepts a $server variable thus we can use it for biblios authorities or other zebra dbs - my ( $biblionumber, $op, $server, $record ) = @_; - $debug && warn "ModZebra: update requested for: $biblionumber $op $server\n"; - if ( C4::Context->preference('SearchEngine') eq 'Elasticsearch' ) { - - # TODO abstract to a standard API that'll work for whatever - require Koha::SearchEngine::Elasticsearch::Indexer; - my $indexer = Koha::SearchEngine::Elasticsearch::Indexer->new( - { - index => $server eq 'biblioserver' - ? $Koha::SearchEngine::BIBLIOS_INDEX - : $Koha::SearchEngine::AUTHORITIES_INDEX - } - ); - if ( $op eq 'specialUpdate' ) { - unless ($record) { - $record = GetMarcBiblio({ - biblionumber => $biblionumber, - embed_items => 1 }); - } - $indexer->update_index_background( [$biblionumber], [$record] ); - } - elsif ( $op eq 'recordDelete' ) { - $indexer->delete_index_background( [$biblionumber] ); - } - else { - croak "ModZebra called with unknown operation: $op"; - } - } - + my ( $biblionumber, $op, $server ) = @_; + $debug && warn "ModZebra: updates requested for: $biblionumber $op $server\n"; my $dbh = C4::Context->dbh; # true ModZebra commented until indexdata fixes zebraDB crashes (it seems they occur on multiple updates @@ -3147,7 +3119,8 @@ sub ModBiblioMarc { $m_rs->metadata( $record->as_xml_record($encoding) ); $m_rs->store; - ModZebra( $biblionumber, "specialUpdate", "biblioserver" ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" ); return $biblionumber; } diff --git a/C4/Circulation.pm b/C4/Circulation.pm index 3571051e84..203370e1a3 100644 --- a/C4/Circulation.pm +++ b/C4/Circulation.pm @@ -3716,9 +3716,23 @@ sub ReturnLostItem{ MarkIssueReturned( $borrowernumber, $itemnum ); } +=head2 LostItem + + LostItem( $itemnumber, $mark_lost_from, $force_mark_returned, [$params] ); + +The final optional parameter, C<$params>, expected to contain +'skip_modzebra_update' key, which relayed down to Koha::Item/store, +there it prevents calling of ModZebra index_records, +which takes most of the time in batch adds/deletes: index_records better +to be called later in C after the whole loop. + +$params: + skip_modzebra_update => 1|0 + +=cut sub LostItem{ - my ($itemnumber, $mark_lost_from, $force_mark_returned) = @_; + my ($itemnumber, $mark_lost_from, $force_mark_returned, $params) = @_; unless ( $mark_lost_from ) { # Temporary check to avoid regressions @@ -3769,7 +3783,7 @@ sub LostItem{ #When item is marked lost automatically cancel its outstanding transfers and set items holdingbranch to the transfer source branch (frombranch) if (my ( $datesent,$frombranch,$tobranch ) = GetTransfers($itemnumber)) { - Koha::Items->find($itemnumber)->holdingbranch($frombranch)->store; + Koha::Items->find($itemnumber)->holdingbranch($frombranch)->store({ skip_modzebra_update => $params->{skip_modzebra_update} }); } my $transferdeleted = DeleteTransfer($itemnumber); } diff --git a/C4/Items.pm b/C4/Items.pm index 22ac2de12c..53951abfc3 100644 --- a/C4/Items.pm +++ b/C4/Items.pm @@ -70,6 +70,7 @@ use Koha::Biblioitems; use Koha::Items; use Koha::ItemTypes; use Koha::SearchEngine; +use Koha::SearchEngine::Indexer; use Koha::SearchEngine::Search; use Koha::Libraries; @@ -144,8 +145,8 @@ record and a biblionumber, create a new item record. The final optional parameter, C<$params>, expected to contain 'skip_modzebra_update' key, which relayed down to Koha::Item/store, -there it prevents calling of ModZebra (and Elasticsearch update), -which takes most of the time in batch adds/deletes: ModZebra better +there it prevents calling of index_records, +which takes most of the time in batch adds/deletes: index_records to be called later in C after the whole loop. $params: @@ -281,10 +282,23 @@ sub AddItemBatchFromMarc { return (\@itemnumbers, \@errors); } +=head2 ModItemFromMarc + +my $item = ModItemFromMarc($item_marc, $biblionumber, $itemnumber[, $params]); + +The final optional parameter, C<$params>, expected to contain +'skip_modzebra_update' key, which relayed down to Koha::Item/store, +there it prevents calling of index_records, +which takes most of the time in batch adds/deletes: index_records better +to be called later in C after the whole loop. + +$params: + skip_modzebra_update => 1|0 + +=cut + sub ModItemFromMarc { - my $item_marc = shift; - my $biblionumber = shift; - my $itemnumber = shift; + my ( $item_marc, $biblionumber, $itemnumber, $params ) = @_; my $frameworkcode = C4::Biblio::GetFrameworkCode($biblionumber); my ( $itemtag, $itemsubfield ) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" ); @@ -313,7 +327,7 @@ sub ModItemFromMarc { $item_object = $item_object->set_or_blank($item); my $unlinked_item_subfields = _get_unlinked_item_subfields( $localitemmarc, $frameworkcode ); $item_object->more_subfields_xml(_get_unlinked_subfields_xml($unlinked_item_subfields)); - $item_object->store; + $item_object->store({ skip_modzebra_update => $params->{skip_modzebra_update} }); return $item_object->unblessed; } @@ -1094,8 +1108,9 @@ sub MoveItemFromBiblio { AND biblionumber = ? |, undef, $tobiblioitem, $tobiblio, $itemnumber, $frombiblio ); if ($return == 1) { - ModZebra( $tobiblio, "specialUpdate", "biblioserver" ); - ModZebra( $frombiblio, "specialUpdate", "biblioserver" ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $tobiblio, "specialUpdate", "biblioserver" ); + $indexer->index_records( $frombiblio, "specialUpdate", "biblioserver" ); # Checking if the item we want to move is in an order require C4::Acquisition; my $order = C4::Acquisition::GetOrderFromItemnumber($itemnumber); diff --git a/Koha/Item.pm b/Koha/Item.pm index 349cf651a4..2082b6350e 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -30,12 +30,12 @@ use Koha::DateUtils qw( dt_from_string ); use C4::Context; use C4::Circulation; use C4::Reserves; -use C4::Biblio qw( ModZebra ); # FIXME This is terrible, we should move the indexation code outside of C4::Biblio use C4::ClassSource; # FIXME We would like to avoid that use C4::Log qw( logaction ); use Koha::Checkouts; use Koha::CirculationRules; +use Koha::SearchEngine::Indexer; use Koha::Item::Transfer::Limits; use Koha::Item::Transfers; use Koha::ItemTypes; @@ -62,7 +62,7 @@ Koha::Item - Koha Item object class $item->store; $params can take an optional 'skip_modzebra_update' parameter. -If set, the reindexation process will not happen (ModZebra not called) +If set, the reindexation process will not happen (index_records not called) NOTE: This is a temporary fix to answer a performance issue when lot of items are added (or modified) at the same time. @@ -111,7 +111,8 @@ sub store { $self->cn_sort($cn_sort); } - C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" ) + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" ) unless $params->{skip_modzebra_update}; logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" ) @@ -187,7 +188,8 @@ sub store { $self->paidfor(''); } - C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" ) + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" ) unless $params->{skip_modzebra_update}; $self->_after_item_action_hooks({ action => 'modify' }); @@ -214,7 +216,8 @@ sub delete { # FIXME check the item has no current issues # i.e. raise the appropriate exception - C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" ) + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $self->biblionumber, "specialUpdate", "biblioserver" ) unless $params->{skip_modzebra_update}; $self->_after_item_action_hooks({ action => 'delete' }); diff --git a/Koha/SearchEngine/Elasticsearch/Indexer.pm b/Koha/SearchEngine/Elasticsearch/Indexer.pm index e2c1c91fbd..8c8e44c79b 100644 --- a/Koha/SearchEngine/Elasticsearch/Indexer.pm +++ b/Koha/SearchEngine/Elasticsearch/Indexer.pm @@ -25,6 +25,8 @@ use base qw(Koha::SearchEngine::Elasticsearch); use Data::Dumper; use Koha::Exceptions; +use Koha::SearchEngine::Zebra::Indexer; +use C4::Biblio; use C4::Context; =head1 NAME @@ -277,6 +279,41 @@ sub update_index_background { $self->update_index(@_); } +=head2 index_records + +This function takes an array of biblionumbers and fetches the records to send to update_index +for actual indexing. + +If $records parameter is provided the records will be used as-is, this is only utilized for authorities +at the moment. + +The other variables are used for parity with Zebra indexing calls. Currently the calls are passed through +to Zebra as well. + +=cut + +sub index_records { + my ( $self, $biblionumbers, $op, $server, $records ) = @_; + $biblionumbers = [$biblionumbers] if ref $biblionumbers ne 'ARRAY' && defined $biblionumbers; + $records = [$records] if ref $biblionumbers ne 'ARRAY' && defined $records; + if ( $op eq 'specialUpdate' ) { + unless ($records) { + foreach my $biblionumber ( @$biblionumbers ){ + my $record = C4::Biblio::GetMarcBiblio({ + biblionumber => $biblionumber, + embed_items => 1 }); + push @$records, $record; + } + } + $self->update_index_background( $biblionumbers, $records ); + } + elsif ( $op eq 'recordDelete' ) { + $self->delete_index_background( $biblionumbers ); + } + #FIXME Current behaviour is to index Zebra when using ES, at some point we should stop + Koha::SearchEngine::Zebra::Indexer::index_records( $self, $biblionumbers, $op, $server, undef ); +} + =head2 delete_index($biblionums) C<$biblionums> is an arrayref of biblionumbers to delete from the index. diff --git a/Koha/SearchEngine/Indexer.pm b/Koha/SearchEngine/Indexer.pm new file mode 100644 index 0000000000..64262ff587 --- /dev/null +++ b/Koha/SearchEngine/Indexer.pm @@ -0,0 +1,59 @@ +package Koha::SearchEngine::Indexer; + +# This file is part of Koha. +# +# Copyright 2020 ByWater Solutions +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +# This is a shim that gives you the appropriate search object for your +# system preference. + +=head1 NAME + +Koha::SearchEngine::Indexer - instantiate the search object that corresponds to +the C system preference. + +=head1 DESCRIPTION + +This allows you to be agnostic about what the search engine configuration is +and just get whatever indexer object you need. + +=head1 SYNOPSIS + + use Koha::SearchEngine::Indexer; + my $searcher = Koha::SearchEngine::Indexer->new({ index => Koha::SearchEngine::Index}); + +=head1 METHODS + +=head2 new + +Creates a new C of whatever the relevant type is. + +=cut + +use Modern::Perl; +use C4::Context; +use C4::Biblio qw//; + +sub new { + my $engine = C4::Context->preference("SearchEngine") // 'Zebra'; + my $file = "Koha/SearchEngine/${engine}/Indexer.pm"; + my $class = "Koha::SearchEngine::${engine}::Indexer"; + require $file; + shift @_; + return $class->new(@_); +} + +1; diff --git a/Koha/SearchEngine/Zebra/Indexer.pm b/Koha/SearchEngine/Zebra/Indexer.pm new file mode 100644 index 0000000000..29213dd8fc --- /dev/null +++ b/Koha/SearchEngine/Zebra/Indexer.pm @@ -0,0 +1,65 @@ +package Koha::SearchEngine::Zebra::Indexer; + +# Copyright 2020 ByWater Solutions +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use C4::Biblio qw(ModZebra); # FIXME This is terrible, we should move the indexation code outside of C4::Biblio +use base qw(Class::Accessor); + +=head1 NAME + +Koha::SearchEngine::Elasticsearch::Indexer - handles adding new records to the index + +=head1 SYNOPSIS + + my $indexer = Koha::SearchEngine::Zebra::Indexer->new(); + $indexer->index_records( $biblionumbers, $op, $server, $records); + + +=head1 FUNCTIONS + +=head2 new + + This is a dummy function to create the object. C4::Biblio->ModZebra is doing the real work + now and needed variables are passed to index_records + +=cut + +sub new { + my $class = shift @_; + my $self = $class->SUPER::new(@_); +} + +=head2 index_records($biblionumbers, $op, $server, $records) + + This is simply a wrapper to C4::Biblio::ModZebra that takes an array of records and + passes them through individually + + The final parameter $records is not used in Zebra, it exists for parity with Elasticsearch calls + +=cut + +sub index_records { + my ( $self, $biblionumbers, $op, $server, $records ) = @_; + $biblionumbers = [$biblionumbers] if ref $biblionumbers ne 'ARRAY' && defined $biblionumbers; + foreach my $biblionumber ( @$biblionumbers ){ + ModZebra( $biblionumber, $op, $server ); + } +} + +1; diff --git a/cataloguing/additem.pl b/cataloguing/additem.pl index a2aea9b6ee..34504c37a6 100755 --- a/cataloguing/additem.pl +++ b/cataloguing/additem.pl @@ -35,6 +35,7 @@ use Koha::Items; use Koha::ItemTypes; use Koha::Libraries; use Koha::Patrons; +use Koha::SearchEngine::Indexer; use List::MoreUtils qw/any/; use C4::Search; use Storable qw(thaw freeze); @@ -629,7 +630,8 @@ if ($op eq "additem") { $oldbarcode = $barcodevalue; } - C4::Biblio::ModZebra( $biblionumber, "specialUpdate", "biblioserver" ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" ); undef($itemrecord); } @@ -707,7 +709,8 @@ if ($op eq "additem") { next if ref $error eq 'Koha::Item'; # Deleted item is returned if deletion successful push @errors,$error; } - C4::Biblio::ModZebra( $biblionumber, "specialUpdate", "biblioserver" ); + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" ); if ( @errors ) { $nextop="additem"; } else { diff --git a/tools/batchMod.pl b/tools/batchMod.pl index 39df0f8f1d..ba7d970e17 100755 --- a/tools/batchMod.pl +++ b/tools/batchMod.pl @@ -44,6 +44,7 @@ use Koha::DateUtils; use Koha::Items; use Koha::ItemTypes; use Koha::Patrons; +use Koha::SearchEngine::Indexer; my $input = new CGI; my $dbh = C4::Context->dbh; @@ -192,6 +193,7 @@ if ($op eq "action") { $ynhash->{'av'.$yn->authorised_value} = $yn->lib; } + my $biblionumbers; try { my $schema = Koha::Database->new->schema; $schema->txn_do( @@ -210,6 +212,7 @@ if ($op eq "action") { my $return = $item->safe_delete; if ( ref( $return ) ) { $deleted_items++; + push @$biblionumbers, $itemdata->{'biblionumber'}; } else { $not_deleted_items++; @@ -298,15 +301,21 @@ if ($op eq "action") { my $item = ModItemFromMarc( $localmarcitem, $itemdata->{biblionumber}, - $itemnumber + $itemnumber, + { skip_modzebra_update => 1 }, ) ) { - LostItem( $itemnumber, 'batchmod' ) - if $item->{itemlost} + LostItem( + $itemnumber, + 'batchmod', + undef, + { skip_modzebra_update => 1 } + ) if $item->{itemlost} and not $itemdata->{itemlost}; } }; + push @$biblionumbers, $itemdata->{'biblionumber'}; } } if ($runinbackground) { @@ -336,7 +345,11 @@ if ($op eq "action") { } die "Something terrible has happened!" if ($_ =~ /Rollback failed/); # Rollback failed - } + }; + $biblionumbers = [ uniq @$biblionumbers ]; + my $op = $del ? "delete" : "specialUpdate"; + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); + $indexer->index_records( $biblionumbers, $op, "biblioserver", undef ); } } # -- 2.11.0