]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/Search.pm
search: allow testing with current xapian.git and 1.5.x
[public-inbox.git] / lib / PublicInbox / Search.pm
1 # Copyright (C) 2015-2020 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 # based on notmuch, but with no concept of folders, files or flags
4 #
5 # Read-only search interface for use by the web and NNTP interfaces
6 package PublicInbox::Search;
7 use strict;
8 use parent qw(Exporter);
9 our @EXPORT_OK = qw(mdocid);
10
11 # values for searching, changing the numeric value breaks
12 # compatibility with old indices (so don't change them it)
13 use constant {
14         TS => 0, # Received: header in Unix time (IMAP INTERNALDATE)
15         YYYYMMDD => 1, # Date: header for searching in the WWW UI
16         DT => 2, # Date: YYYYMMDDHHMMSS
17
18         # added for public-inbox 1.6.0+
19         BYTES => 3, # IMAP RFC822.SIZE
20         UID => 4, # IMAP UID == NNTP article number == Xapian docid
21         THREADID => 5, # RFC 8474, RFC 8621
22
23         # TODO
24         # REPLYCNT => ?, # IMAP ANSWERED
25
26         # SCHEMA_VERSION history
27         # 0 - initial
28         # 1 - subject_path is lower-cased
29         # 2 - subject_path is id_compress in the index, only
30         # 3 - message-ID is compressed if it includes '%' (hack!)
31         # 4 - change "Re: " normalization, avoid circular Reference ghosts
32         # 5 - subject_path drops trailing '.'
33         # 6 - preserve References: order in document data
34         # 7 - remove references and inreplyto terms
35         # 8 - remove redundant/unneeded document data
36         # 9 - disable Message-ID compression (SHA-1)
37         # 10 - optimize doc for NNTP overviews
38         # 11 - merge threads when vivifying ghosts
39         # 12 - change YYYYMMDD value column to numeric
40         # 13 - fix threading for empty References/In-Reply-To
41         #      (commit 83425ef12e4b65cdcecd11ddcb38175d4a91d5a0)
42         # 14 - fix ghost root vivification
43         # 15 - see public-inbox-v2-format(5)
44         #      further bumps likely unnecessary, we'll suggest in-place
45         #      "--reindex" use for further fixes and tweaks:
46         #
47         #      public-inbox v1.5.0 adds (still SCHEMA_VERSION=15):
48         #      * "lid:" and "l:" for List-Id searches
49         #
50         #      v1.6.0 adds BYTES, UID and THREADID values
51         SCHEMA_VERSION => 15,
52 };
53
54 use PublicInbox::Smsg;
55 use PublicInbox::Over;
56 my $QP_FLAGS;
57 our %X = map { $_ => 0 } qw(BoolWeight Database Enquire QueryParser Stem);
58 our $Xap; # 'Search::Xapian' or 'Xapian'
59 my $NVRP; # '$Xap::'.('NumberValueRangeProcessor' or 'NumberRangeProcessor')
60 my $ENQ_ASCENDING;
61
62 sub load_xapian () {
63         return 1 if defined $Xap;
64         # n.b. PI_XAPIAN is intended for development use only.  We still
65         # favor Search::Xapian since that's what's available in current
66         # Debian stable (10.x) and derived distros.
67         for my $x (($ENV{PI_XAPIAN} // 'Search::Xapian'), 'Xapian') {
68                 eval "require $x";
69                 next if $@;
70
71                 $x->import(qw(:standard));
72                 $Xap = $x;
73
74                 # `version_string' was added in Xapian 1.1
75                 my $xver = eval('v'.eval($x.'::version_string()')) //
76                                 eval('v'.eval($x.'::xapian_version_string()'));
77
78                 # NumberRangeProcessor was added in Xapian 1.3.6,
79                 # NumberValueRangeProcessor was removed for 1.5.0+,
80                 # favor the older /Value/ variant since that's what our
81                 # (currently) preferred Search::Xapian supports
82                 $NVRP = $x.'::'.($x eq 'Xapian' && $xver ge v1.5 ?
83                         'NumberRangeProcessor' : 'NumberValueRangeProcessor');
84                 $X{$_} = $Xap.'::'.$_ for (keys %X);
85
86                 # ENQ_ASCENDING doesn't seem exported by SWIG Xapian.pm,
87                 # so lets hope this part of the ABI is stable because it's
88                 # just an integer:
89                 $ENQ_ASCENDING = $x eq 'Xapian' ?
90                                 1 : Search::Xapian::ENQ_ASCENDING();
91
92                 # for Smsg:
93                 *PublicInbox::Smsg::sortable_unserialise =
94                                                 $Xap.'::sortable_unserialise';
95                 # n.b. FLAG_PURE_NOT is expensive not suitable for a public
96                 # website as it could become a denial-of-service vector
97                 # FLAG_PHRASE also seems to cause performance problems chert
98                 # (and probably earlier Xapian DBs).  glass seems fine...
99                 # TODO: make this an option, maybe?
100                 # or make indexlevel=medium as default
101                 $QP_FLAGS = FLAG_PHRASE() | FLAG_BOOLEAN() | FLAG_LOVEHATE() |
102                                 FLAG_WILDCARD();
103                 return 1;
104         }
105         undef;
106 }
107
108 # This is English-only, everything else is non-standard and may be confused as
109 # a prefix common in patch emails
110 our $LANG = 'english';
111
112 # note: the non-X term prefix allocations are shared with
113 # Xapian omega, see xapian-applications/omega/docs/termprefixes.rst
114 my %bool_pfx_external = (
115         mid => 'Q', # Message-ID (full/exact), this is mostly uniQue
116         lid => 'G', # newsGroup (or similar entity), just inside <>
117         dfpre => 'XDFPRE',
118         dfpost => 'XDFPOST',
119         dfblob => 'XDFPRE XDFPOST',
120 );
121
122 my $non_quoted_body = 'XNQ XDFN XDFA XDFB XDFHH XDFCTX XDFPRE XDFPOST';
123 my %prob_prefix = (
124         # for mairix compatibility
125         s => 'S',
126         m => 'XM', # 'mid:' (bool) is exact, 'm:' (prob) can do partial
127         l => 'XL', # 'lid:' (bool) is exact, 'l:' (prob) can do partial
128         f => 'A',
129         t => 'XTO',
130         tc => 'XTO XCC',
131         c => 'XCC',
132         tcf => 'XTO XCC A',
133         a => 'XTO XCC A',
134         b => $non_quoted_body . ' XQUOT',
135         bs => $non_quoted_body . ' XQUOT S',
136         n => 'XFN',
137
138         q => 'XQUOT',
139         nq => $non_quoted_body,
140         dfn => 'XDFN',
141         dfa => 'XDFA',
142         dfb => 'XDFB',
143         dfhh => 'XDFHH',
144         dfctx => 'XDFCTX',
145
146         # default:
147         '' => 'XM S A XQUOT XFN ' . $non_quoted_body,
148 );
149
150 # not documenting m: and mid: for now, the using the URLs works w/o Xapian
151 # not documenting lid: for now, either, it is probably redundant with l:,
152 # especially since we don't offer boolean searches for To/Cc/From
153 # headers, either
154 our @HELP = (
155         's:' => 'match within Subject  e.g. s:"a quick brown fox"',
156         'd:' => <<EOF,
157 date range as YYYYMMDD  e.g. d:19931002..20101002
158 Open-ended ranges such as d:19931002.. and d:..20101002
159 are also supported
160 EOF
161         'dt:' => <<EOF,
162 date-time range as YYYYMMDDhhmmss (e.g. dt:19931002011000..19931002011200)
163 EOF
164         'b:' => 'match within message body, including text attachments',
165         'nq:' => 'match non-quoted text within message body',
166         'q:' => 'match quoted text within message body',
167         'n:' => 'match filename of attachment(s)',
168         't:' => 'match within the To header',
169         'c:' => 'match within the Cc header',
170         'f:' => 'match within the From header',
171         'a:' => 'match within the To, Cc, and From headers',
172         'tc:' => 'match within the To and Cc headers',
173         'l:' => 'match contents of the List-Id header',
174         'bs:' => 'match within the Subject and body',
175         'dfn:' => 'match filename from diff',
176         'dfa:' => 'match diff removed (-) lines',
177         'dfb:' => 'match diff added (+) lines',
178         'dfhh:' => 'match diff hunk header context (usually a function name)',
179         'dfctx:' => 'match diff context lines',
180         'dfpre:' => 'match pre-image git blob ID',
181         'dfpost:' => 'match post-image git blob ID',
182         'dfblob:' => 'match either pre or post-image git blob ID',
183 );
184 chomp @HELP;
185
186 sub xdir ($;$) {
187         my ($self, $rdonly) = @_;
188         if ($rdonly || !defined($self->{shard})) {
189                 $self->{xpfx};
190         } else { # v2 only:
191                 "$self->{xpfx}/$self->{shard}";
192         }
193 }
194
195 sub _xdb ($) {
196         my ($self) = @_;
197         my $dir = xdir($self, 1);
198         my ($xdb, $slow_phrase);
199         my $qpf = \($self->{qp_flags} ||= $QP_FLAGS);
200         if ($self->{ibx_ver} >= 2) {
201                 my @xdb;
202                 opendir(my $dh, $dir) or return; # not initialized yet
203
204                 # We need numeric sorting so shard[0] is first for reading
205                 # Xapian metadata, if needed
206                 for (sort { $a <=> $b } grep(/\A[0-9]+\z/, readdir($dh))) {
207                         my $shard_dir = "$dir/$_";
208                         if (-d $shard_dir && -r _) {
209                                 push @xdb, $X{Database}->new($shard_dir);
210                                 $slow_phrase ||= -f "$shard_dir/iamchert";
211                         } else { # gaps from missing epochs throw off mdocid()
212                                 warn "E: $shard_dir missing or unreadable\n";
213                                 return;
214                         }
215                 }
216                 $self->{nshard} = scalar(@xdb);
217                 $xdb = shift @xdb;
218                 $xdb->add_database($_) for @xdb;
219         } else {
220                 $slow_phrase = -f "$dir/iamchert";
221                 $xdb = $X{Database}->new($dir);
222         }
223         $$qpf |= FLAG_PHRASE() unless $slow_phrase;
224         $xdb;
225 }
226
227 # v2 Xapian docids don't conflict, so they're identical to
228 # NNTP article numbers and IMAP UIDs.
229 # https://trac.xapian.org/wiki/FAQ/MultiDatabaseDocumentID
230 sub mdocid {
231         my ($nshard, $mitem) = @_;
232         my $docid = $mitem->get_docid;
233         int(($docid - 1) / $nshard) + 1;
234 }
235
236 sub mset_to_artnums {
237         my ($self, $mset) = @_;
238         my $nshard = $self->{nshard} // 1;
239         [ map { mdocid($nshard, $_) } $mset->items ];
240 }
241
242 sub xdb ($) {
243         my ($self) = @_;
244         $self->{xdb} ||= do {
245                 load_xapian();
246                 _xdb($self);
247         };
248 }
249
250 sub xpfx_init ($) {
251         my ($self) = @_;
252         if ($self->{ibx_ver} == 1) {
253                 $self->{xpfx} .= '/public-inbox/xapian' . SCHEMA_VERSION;
254         } else {
255                 $self->{xpfx} .= '/xap'.SCHEMA_VERSION;
256         }
257 }
258
259 sub new {
260         my ($class, $ibx) = @_;
261         ref $ibx or die "BUG: expected PublicInbox::Inbox object: $ibx";
262         my $self = bless {
263                 xpfx => $ibx->{inboxdir}, # for xpfx_init
264                 altid => $ibx->{altid},
265                 ibx_ver => $ibx->version,
266         }, $class;
267         xpfx_init($self);
268         my $dir = xdir($self, 1);
269         $self->{over_ro} = PublicInbox::Over->new("$dir/over.sqlite3");
270         $self;
271 }
272
273 sub reopen {
274         my ($self) = @_;
275         if (my $xdb = $self->{xdb}) {
276                 $xdb->reopen;
277         }
278         $self; # make chaining easier
279 }
280
281 # read-only
282 sub query {
283         my ($self, $query_string, $opts) = @_;
284         $opts ||= {};
285         if ($query_string eq '' && !$opts->{mset}) {
286                 $self->{over_ro}->recent($opts);
287         } else {
288                 my $qp = $self->{qp} //= qparse_new($self);
289                 my $qp_flags = $self->{qp_flags};
290                 my $query = $qp->parse_query($query_string, $qp_flags);
291                 $opts->{relevance} = 1 unless exists $opts->{relevance};
292                 _do_enquire($self, $query, $opts);
293         }
294 }
295
296 sub retry_reopen {
297         my ($self, $cb, $arg) = @_;
298         for my $i (1..10) {
299                 if (wantarray) {
300                         my @ret;
301                         eval { @ret = $cb->($arg) };
302                         return @ret unless $@;
303                 } else {
304                         my $ret;
305                         eval { $ret = $cb->($arg) };
306                         return $ret unless $@;
307                 }
308                 # Exception: The revision being read has been discarded -
309                 # you should call Xapian::Database::reopen()
310                 if (ref($@) =~ /\bDatabaseModifiedError\b/) {
311                         warn "reopen try #$i on $@\n";
312                         reopen($self);
313                 } else {
314                         # let caller decide how to spew, because ExtMsg queries
315                         # get wonky and trigger:
316                         # "something terrible happened at .../Xapian/Enquire.pm"
317                         die;
318                 }
319         }
320         die "Too many Xapian database modifications in progress\n";
321 }
322
323 sub _do_enquire {
324         my ($self, $query, $opts) = @_;
325         retry_reopen($self, \&_enquire_once, [ $self, $query, $opts ]);
326 }
327
328 # returns true if all docs have the THREADID value
329 sub has_threadid ($) {
330         my ($self) = @_;
331         (xdb($self)->get_metadata('has_threadid') // '') eq '1';
332 }
333
334 sub _enquire_once { # retry_reopen callback
335         my ($self, $query, $opts) = @{$_[0]};
336         my $xdb = xdb($self);
337         my $enquire = $X{Enquire}->new($xdb);
338         $enquire->set_query($query);
339         $opts ||= {};
340         my $desc = !$opts->{asc};
341         if (($opts->{mset} || 0) == 2) { # mset == 2: ORDER BY docid/UID
342                 $enquire->set_docid_order($ENQ_ASCENDING);
343                 $enquire->set_weighting_scheme($X{BoolWeight}->new);
344         } elsif ($opts->{relevance}) {
345                 $enquire->set_sort_by_relevance_then_value(TS, $desc);
346         } else {
347                 $enquire->set_sort_by_value_then_relevance(TS, $desc);
348         }
349
350         # `mairix -t / --threads' or JMAP collapseThreads
351         if ($opts->{thread} && has_threadid($self)) {
352                 $enquire->set_collapse_key(THREADID);
353         }
354
355         my $offset = $opts->{offset} || 0;
356         my $limit = $opts->{limit} || 50;
357         my $mset = $enquire->get_mset($offset, $limit);
358         return $mset if $opts->{mset};
359         my $nshard = $self->{nshard} // 1;
360         my $i = 0;
361         my %order = map { mdocid($nshard, $_) => ++$i } $mset->items;
362         my @msgs = sort {
363                 $order{$a->{num}} <=> $order{$b->{num}}
364         } @{$self->{over_ro}->get_all(keys %order)};
365         wantarray ? ($mset->get_matches_estimated, \@msgs) : \@msgs;
366 }
367
368 # read-write
369 sub stemmer { $X{Stem}->new($LANG) }
370
371 # read-only
372 sub qparse_new ($) {
373         my ($self) = @_;
374
375         my $xdb = xdb($self);
376         my $qp = $X{QueryParser}->new;
377         $qp->set_default_op(OP_AND());
378         $qp->set_database($xdb);
379         $qp->set_stemmer(stemmer($self));
380         $qp->set_stemming_strategy(STEM_SOME());
381         my $cb = $qp->can('set_max_wildcard_expansion') //
382                 $qp->can('set_max_expansion'); # Xapian 1.5.0+
383         $cb->($qp, 100);
384         $cb = $qp->can('add_valuerangeprocessor') //
385                 $qp->can('add_rangeprocessor'); # Xapian 1.5.0+
386         $cb->($qp, $NVRP->new(YYYYMMDD, 'd:'));
387         $cb->($qp, $NVRP->new(DT, 'dt:'));
388
389         # for IMAP, undocumented for WWW and may be split off go away
390         $cb->($qp, $NVRP->new(BYTES, 'bytes:'));
391         $cb->($qp, $NVRP->new(TS, 'ts:'));
392         $cb->($qp, $NVRP->new(UID, 'uid:'));
393
394         while (my ($name, $prefix) = each %bool_pfx_external) {
395                 $qp->add_boolean_prefix($name, $_) foreach split(/ /, $prefix);
396         }
397
398         # we do not actually create AltId objects,
399         # just parse the spec to avoid the extra DB handles for now.
400         if (my $altid = $self->{altid}) {
401                 my $user_pfx = $self->{-user_pfx} = [];
402                 for (@$altid) {
403                         # $_ = 'serial:gmane:/path/to/gmane.msgmap.sqlite3'
404                         # note: Xapian supports multibyte UTF-8, /^[0-9]+$/,
405                         # and '_' with prefixes matching \w+
406                         /\Aserial:(\w+):/ or next;
407                         my $pfx = $1;
408                         push @$user_pfx, "$pfx:", <<EOF;
409 alternate serial number  e.g. $pfx:12345 (boolean)
410 EOF
411                         # gmane => XGMANE
412                         $qp->add_boolean_prefix($pfx, 'X'.uc($pfx));
413                 }
414                 chomp @$user_pfx;
415         }
416
417         while (my ($name, $prefix) = each %prob_prefix) {
418                 $qp->add_prefix($name, $_) foreach split(/ /, $prefix);
419         }
420         $qp;
421 }
422
423 sub help {
424         my ($self) = @_;
425         $self->{qp} //= qparse_new($self); # parse altids
426         my @ret = @HELP;
427         if (my $user_pfx = $self->{-user_pfx}) {
428                 push @ret, @$user_pfx;
429         }
430         \@ret;
431 }
432
433 1;