rt 4.2.15
[freeside.git] / rt / sbin / rt-fulltext-indexer.in
1 #!@PERL@
2 # BEGIN BPS TAGGED BLOCK {{{
3 #
4 # COPYRIGHT:
5 #
6 # This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
7 #                                          <sales@bestpractical.com>
8 #
9 # (Except where explicitly superseded by other copyright notices)
10 #
11 #
12 # LICENSE:
13 #
14 # This work is made available to you under the terms of Version 2 of
15 # the GNU General Public License. A copy of that license should have
16 # been provided with this software, but in any event can be snarfed
17 # from www.gnu.org.
18 #
19 # This work is distributed in the hope that it will be useful, but
20 # WITHOUT ANY WARRANTY; without even the implied warranty of
21 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
22 # General Public License for more details.
23 #
24 # You should have received a copy of the GNU General Public License
25 # along with this program; if not, write to the Free Software
26 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
27 # 02110-1301 or visit their web page on the internet at
28 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
29 #
30 #
31 # CONTRIBUTION SUBMISSION POLICY:
32 #
33 # (The following paragraph is not intended to limit the rights granted
34 # to you to modify and distribute this software under the terms of
35 # the GNU General Public License and is only of importance to you if
36 # you choose to contribute your changes and enhancements to the
37 # community by submitting them to Best Practical Solutions, LLC.)
38 #
39 # By intentionally submitting any modifications, corrections or
40 # derivatives to this work, or any other work intended for use with
41 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
42 # you are the copyright holder for those contributions and you grant
43 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
44 # royalty-free, perpetual, license to use, copy, create derivative
45 # works based on those contributions, and sublicense and distribute
46 # those contributions and any derivatives thereof.
47 #
48 # END BPS TAGGED BLOCK }}}
49 use strict;
50 use warnings;
51 use 5.010;
52
53 # fix lib paths, some may be relative
54 BEGIN { # BEGIN RT CMD BOILERPLATE
55     require File::Spec;
56     require Cwd;
57     my @libs = ("@RT_LIB_PATH@", "@LOCAL_LIB_PATH@");
58     my $bin_path;
59
60     for my $lib (@libs) {
61         unless ( File::Spec->file_name_is_absolute($lib) ) {
62             $bin_path ||= ( File::Spec->splitpath(Cwd::abs_path(__FILE__)) )[1];
63             $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
64         }
65         unshift @INC, $lib;
66     }
67
68 }
69
70 use RT -init;
71 use RT::Interface::CLI ();
72 use HTML::Entities;
73
74 use Getopt::Long qw(GetOptions);
75 my %OPT = ( memory => '2M', limit => 0 );
76 GetOptions( \%OPT,
77     "help|h!",
78     "debug!",
79     "quiet!",
80
81     "all!",
82     "limit=i",
83
84     "memory=s",
85 );
86 $OPT{limit} ||= 200;
87
88 RT::Interface::CLI->ShowHelp if $OPT{help};
89
90 use Fcntl ':flock';
91 if ( !flock main::DATA, LOCK_EX | LOCK_NB ) {
92     if ( $OPT{quiet} ) {
93         RT::Logger->info("$0 is already running; aborting silently, as requested");
94         exit;
95     }
96     else {
97         print STDERR "$0 is already running\n";
98         exit 1;
99     }
100 }
101
102 my $max_size = RT->Config->Get('MaxFulltextAttachmentSize');
103
104 my $db_type = RT->Config->Get('DatabaseType');
105 my $fts_config = $ENV{RT_FTS_CONFIG} ? JSON::from_json($ENV{RT_FTS_CONFIG})
106     : RT->Config->Get('FullTextSearch') || {};
107 unless ( $fts_config->{'Enable'} ) {
108     print STDERR <<EOT;
109
110 Full text search is disabled in your RT configuration.  Run
111 @RT_SBIN_PATH_R@/rt-setup-fulltext-index to configure and enable it.
112
113 EOT
114     exit 1;
115 }
116 unless ( $fts_config->{'Indexed'} ) {
117     print STDERR <<EOT;
118
119 Full text search is enabled in your RT configuration, but not with any
120 full-text database indexing -- hence this tool is not required.  Read
121 the documentation for %FullTextSearch in your RT_Config for more details.
122
123 EOT
124     exit 1;
125 }
126
127 if ( $db_type eq 'Oracle' ) {
128     my $index = $fts_config->{'IndexName'} || 'rt_fts_index';
129     $RT::Handle->dbh->do(
130         "begin ctx_ddl.sync_index(?, ?); end;", undef,
131         $index, $OPT{'memory'}
132     );
133     exit;
134 } elsif ( $fts_config->{Sphinx} ) {
135     print STDERR <<EOT;
136
137 Updates to the external Sphinx index are done via running the sphinx
138 `indexer` tool:
139
140     indexer rt
141
142 EOT
143     exit 1;
144 }
145
146 # Skip ACL checks.  This saves a large number of unnecessary queries
147 # (for tickets, ACLs, and users) which are unnecessary, as we are
148 # running as the system user.
149 {
150     no warnings 'redefine';
151     no warnings 'once';
152     *RT::Attachment::_Value = \&DBIx::SearchBuilder::Record::_Value;
153     *RT::Attachments::Next  = \&DBIx::SearchBuilder::Next;
154 }
155
156 my $LAST;
157 if ($db_type eq 'mysql') {
158     process_mysql();
159 } elsif ($db_type eq 'Pg') {
160     process_pg();
161 }
162
163 sub attachment_loop {
164     my $subref = shift;
165     my $table = $fts_config->{'Table'};
166     $LAST //= 0;
167
168     # Fetch in batches of size --limit
169     {
170         # Indexes all text/plain and text/html attachments
171         my $attachments = RT::Attachments->new( RT->SystemUser );
172         $attachments->Limit(
173             FIELD    => 'ContentType',
174             OPERATOR => 'IN',
175             VALUE    => ['text/plain', 'text/html'],
176         );
177         $attachments->Limit( FIELD => 'id', OPERATOR => '>', VALUE => $LAST );
178         $attachments->OrderBy( FIELD => 'id', ORDER => 'asc' );
179         $attachments->RowsPerPage( $OPT{'limit'} );
180
181         # Call back to the DB-specific part
182         $subref->($attachments);
183
184         $LAST = $attachments->Last->id if $attachments->Count;
185
186         redo if $OPT{'all'} and $attachments->Count == $OPT{'limit'};
187     }
188 }
189
190 sub process_bulk_insert {
191     my $dbh = $RT::Handle->dbh;
192     my ($statement, $error) = @_;
193
194     # Doing large inserts is faster than individual statements, but
195     # comes at a parsing cost; cache the statement handles (99% of which
196     # will be the same size) for a notable (2x) speed gain.
197     my %sthandles;
198
199     $sthandles{1} =
200         $dbh->prepare($statement->(1));
201
202     attachment_loop( sub {
203         my ($attachments) = @_;
204         my @insert;
205         my $found = 0;
206
207         while ( my $a = $attachments->Next ) {
208             debug("Found attachment #". $a->id );
209             if ( $max_size and $a->ContentLength > $max_size ){
210                 debug("Attachment #" . $a->id . " is " . $a->ContentLength .
211                       " bytes which is larger than configured MaxFulltextAttachmentSize " .
212                       " of " . $max_size . ", skipping");
213                 next;
214             }
215
216             my $text = $a->Content // "";
217             HTML::Entities::decode_entities($text) if $a->ContentType eq "text/html";
218             push @insert, $text, $a->id;
219             $found++;
220         }
221         return unless $found;
222
223         # $found should be the limit size on all but the last go-around.
224         $sthandles{$found} ||= $dbh->prepare($statement->($found));
225
226         return if eval { $sthandles{$found}->execute(@insert); };
227
228         # We can catch and recover from some errors; re-do row-by-row to
229         # know which row had which errors
230         while (@insert) {
231             my ($content, $id) = splice(@insert,0,2);
232             next if eval { $sthandles{1}->execute($content, $id); };
233             $error->($id, $content);
234
235             # If this was a semi-expected error, insert an empty
236             # tsvector, so we count this row as "indexed" for
237             # purposes of knowing where to pick up
238             eval { $sthandles{1}->execute( "", $id ) }
239                 or die "Failed to insert empty row for attachment $id: " . $dbh->errstr;
240         }
241     });
242 }
243
244 sub process_mysql {
245     my $dbh = $RT::Handle->dbh;
246     my $table = $fts_config->{'Table'};
247
248     ($LAST) = $dbh->selectrow_array("SELECT MAX(id) FROM $table");
249
250     my $insert = $fts_config->{Engine} eq "MyISAM" ? "INSERT DELAYED" : "INSERT";
251
252     process_bulk_insert(
253         sub {
254             my ($n) = @_;
255             return "$insert INTO $table(Content, id) VALUES "
256                 . join(", ", ("(?,?)") x $n);
257         },
258         sub {
259             my ($id) = @_;
260             if ($dbh->err == 1366 and $dbh->state eq "HY000") {
261                 warn "Attachment $id cannot be indexed. Most probably it contains invalid UTF8 bytes. ".
262                     "Error: ". $dbh->errstr;
263             } else {
264                 die "Attachment $id cannot be indexed: " . $dbh->errstr;
265             }
266         }
267     );
268 }
269
270
271 sub process_pg {
272     if ( $fts_config->{'Table'} ne 'Attachments' ) {
273         process_pg_insert();
274     } else {
275         process_pg_update();
276     }
277 }
278
279 sub process_pg_insert {
280     my $dbh = $RT::Handle->dbh;
281     my $table = $fts_config->{'Table'};
282     my $column = $fts_config->{'Column'};
283     ($LAST) = $dbh->selectrow_array("SELECT MAX(id) FROM $table");
284
285     process_bulk_insert(
286         sub {
287             my ($n) = @_;
288             return "INSERT INTO $table($column, id) VALUES "
289                 . join(", ", ("(TO_TSVECTOR(?),?)") x $n);
290         },
291         sub {
292             my ($id) = @_;
293             if ( $dbh->err == 7 && $dbh->state eq '54000' ) {
294                 warn "Attachment $id cannot be indexed. Most probably it contains too many unique words. ".
295                   "Error: ". $dbh->errstr;
296             } elsif ( $dbh->err == 7 && $dbh->state eq '22021' ) {
297                 warn "Attachment $id cannot be indexed. Most probably it contains invalid UTF8 bytes. ".
298                   "Error: ". $dbh->errstr;
299             } else {
300                 die "Attachment $id cannot be indexed: " . $dbh->errstr;
301             }
302         }
303     );
304 }
305
306 sub process_pg_update {
307     my $dbh = $RT::Handle->dbh;
308     my $column = $fts_config->{'Column'};
309
310     ($LAST) = $dbh->selectrow_array("SELECT MAX(id) FROM Attachments WHERE $column IS NOT NULL");
311
312     my $sth = $dbh->prepare("UPDATE Attachments SET $column = TO_TSVECTOR(?) WHERE id = ?");
313
314     attachment_loop( sub {
315         my ($attachments) = @_;
316         my @insert;
317
318         while ( my $a = $attachments->Next ) {
319             debug("Found attachment #". $a->id );
320
321             if ( $max_size and $a->ContentLength > $max_size ){
322                 debug("Attachment #" . $a->id . " is " . $a->ContentLength .
323                       " bytes which is larger than configured MaxFulltextAttachmentSize " .
324                       " of " . $max_size . ", skipping");
325                 next;
326             }
327
328             my $text = $a->Content // "";
329             HTML::Entities::decode_entities($text) if $a->ContentType eq "text/html";
330
331             push @insert, [$text, $a->id];
332         }
333
334         # Try in one database transaction; if it fails, we roll it back
335         # and try one statement at a time.
336         $dbh->begin_work;
337         my $ok = 1;
338         for (@insert) {
339             $ok = eval { $sth->execute( $_->[0], $_->[1] ) };
340             last unless $ok;
341         }
342         if ($ok) {
343             $dbh->commit;
344             return;
345         }
346         $dbh->rollback;
347
348         # Things didn't go well.  Retry the UPDATE statements one row at
349         # a time, outside of the transaction.
350         for (@insert) {
351             my ($content, $id) = ($_->[0], $_->[1]);
352             next if eval { $sth->execute( $content, $id ) };
353             if ( $dbh->err == 7  && $dbh->state eq '54000' ) {
354                 warn "Attachment $id cannot be indexed. Most probably it contains too many unique words. ".
355                   "Error: ". $dbh->errstr;
356             } elsif ( $dbh->err == 7 && $dbh->state eq '22021' ) {
357                 warn "Attachment $id cannot be indexed. Most probably it contains invalid UTF8 bytes. ".
358                   "Error: ". $dbh->errstr;
359             } else {
360                 die "Attachment $id cannot be indexed: " . $dbh->errstr;
361             }
362
363             # If this was a semi-expected error, insert an empty
364             # tsvector, so we count this row as "indexed" for
365             # purposes of knowing where to pick up
366             eval { $sth->execute( "", $id ) }
367                 or die "Failed to insert empty row for attachment $id: " . $dbh->errstr;
368         }
369     });
370 }
371
372
373 # helper functions
374 sub debug    { print @_, "\n" if $OPT{debug}; 1 }
375 sub error    { $RT::Logger->error(_(@_)); 1 }
376 sub warning  { $RT::Logger->warn(_(@_)); 1 }
377
378 =head1 NAME
379
380 rt-fulltext-indexer - Indexer for full text search
381
382 =head1 DESCRIPTION
383
384 This is a helper script to keep full text indexes in sync with data.
385 Read F<docs/full_text_indexing.pod> for complete details on how and when
386 to run it.
387
388 =cut
389
390 __DATA__