X-Git-Url: http://www.git.stargrave.org/?a=blobdiff_plain;f=lib%2FPublicInbox%2FSearch.pm;h=b739faf1a50c7eae1ae781078a0621fa71e648c4;hb=6e0659d2ae455fb28d433fd8c71b563210a3997a;hp=3ec96ca93a35fc966166c3c08ebb872cf3fb27d1;hpb=956ab5a5362a76cb70488c042d0e087902ccc2b8;p=public-inbox.git diff --git a/lib/PublicInbox/Search.pm b/lib/PublicInbox/Search.pm index 3ec96ca9..b739faf1 100644 --- a/lib/PublicInbox/Search.pm +++ b/lib/PublicInbox/Search.pm @@ -1,30 +1,28 @@ -# Copyright (C) 2015-2018 all contributors +# Copyright (C) 2015-2020 all contributors # License: AGPL-3.0+ # based on notmuch, but with no concept of folders, files or flags # # Read-only search interface for use by the web and NNTP interfaces package PublicInbox::Search; use strict; -use warnings; +use parent qw(Exporter); +our @EXPORT_OK = qw(mdocid); -# values for searching -use constant TS => 0; # timestamp -use constant NUM => 1; # NNTP article number -use constant BYTES => 2; # :bytes as defined in RFC 3977 -use constant LINES => 3; # :lines as defined in RFC 3977 -use constant YYYYMMDD => 4; # for searching in the WWW UI +# values for searching, changing the numeric value breaks +# compatibility with old indices (so don't change them it) +use constant { + TS => 0, # Received: header in Unix time (IMAP INTERNALDATE) + YYYYMMDD => 1, # Date: header for searching in the WWW UI + DT => 2, # Date: YYYYMMDDHHMMSS -use Search::Xapian qw/:standard/; -use PublicInbox::SearchMsg; -use PublicInbox::MIME; -use PublicInbox::MID qw/mid_clean id_compress/; + # added for public-inbox 1.6.0+ + BYTES => 3, # IMAP RFC822.SIZE + UID => 4, # IMAP UID == NNTP article number == Xapian docid + THREADID => 5, # RFC 8474, RFC 8621 -# This is English-only, everything else is non-standard and may be confused as -# a prefix common in patch emails -our $REPLY_RE = qr/^re:\s+/i; -our $LANG = 'english'; + # TODO + # REPLYCNT => ?, # IMAP ANSWERED -use constant { # SCHEMA_VERSION history # 0 - initial # 1 - subject_path is lower-cased @@ -42,59 +40,126 @@ use constant { # 13 - fix threading for empty References/In-Reply-To # (commit 83425ef12e4b65cdcecd11ddcb38175d4a91d5a0) # 14 - fix ghost root vivification - SCHEMA_VERSION => 14, - - # n.b. FLAG_PURE_NOT is expensive not suitable for a public website - # as it could become a denial-of-service vector - QP_FLAGS => FLAG_PHRASE|FLAG_BOOLEAN|FLAG_LOVEHATE|FLAG_WILDCARD, + # 15 - see public-inbox-v2-format(5) + # further bumps likely unnecessary, we'll suggest in-place + # "--reindex" use for further fixes and tweaks: + # + # public-inbox v1.5.0 adds (still SCHEMA_VERSION=15): + # * "lid:" and "l:" for List-Id searches + # + # v1.6.0 adds BYTES, UID and THREADID values + SCHEMA_VERSION => 15, }; -# setup prefixes -my %bool_pfx_internal = ( - type => 'T', # "mail" or "ghost" - thread => 'G', # newsGroup (or similar entity - e.g. a web forum name) -); +use PublicInbox::Smsg; +use PublicInbox::Over; +my $QP_FLAGS; +our %X = map { $_ => 0 } qw(BoolWeight Database Enquire QueryParser Stem); +our $Xap; # 'Search::Xapian' or 'Xapian' +my $NVRP; # '$Xap::'.('NumberValueRangeProcessor' or 'NumberRangeProcessor') +my $ENQ_ASCENDING; + +sub load_xapian () { + return 1 if defined $Xap; + # n.b. PI_XAPIAN is intended for development use only. We still + # favor Search::Xapian since that's what's available in current + # Debian stable (10.x) and derived distros. + for my $x (($ENV{PI_XAPIAN} // 'Search::Xapian'), 'Xapian') { + eval "require $x"; + next if $@; + + $x->import(qw(:standard)); + $Xap = $x; + + # `version_string' was added in Xapian 1.1 + my $xver = eval('v'.eval($x.'::version_string()')) // + eval('v'.eval($x.'::xapian_version_string()')); + + # NumberRangeProcessor was added in Xapian 1.3.6, + # NumberValueRangeProcessor was removed for 1.5.0+, + # favor the older /Value/ variant since that's what our + # (currently) preferred Search::Xapian supports + $NVRP = $x.'::'.($x eq 'Xapian' && $xver ge v1.5 ? + 'NumberRangeProcessor' : 'NumberValueRangeProcessor'); + $X{$_} = $Xap.'::'.$_ for (keys %X); + + # ENQ_ASCENDING doesn't seem exported by SWIG Xapian.pm, + # so lets hope this part of the ABI is stable because it's + # just an integer: + $ENQ_ASCENDING = $x eq 'Xapian' ? + 1 : Search::Xapian::ENQ_ASCENDING(); + + # for Smsg: + *PublicInbox::Smsg::sortable_unserialise = + $Xap.'::sortable_unserialise'; + # n.b. FLAG_PURE_NOT is expensive not suitable for a public + # website as it could become a denial-of-service vector + # FLAG_PHRASE also seems to cause performance problems chert + # (and probably earlier Xapian DBs). glass seems fine... + # TODO: make this an option, maybe? + # or make indexlevel=medium as default + $QP_FLAGS = FLAG_PHRASE() | FLAG_BOOLEAN() | FLAG_LOVEHATE() | + FLAG_WILDCARD(); + return 1; + } + undef; +} +# This is English-only, everything else is non-standard and may be confused as +# a prefix common in patch emails +our $LANG = 'english'; + +# note: the non-X term prefix allocations are shared with +# Xapian omega, see xapian-applications/omega/docs/termprefixes.rst my %bool_pfx_external = ( - mid => 'XMID', # uniQue id (Message-ID) + mid => 'Q', # Message-ID (full/exact), this is mostly uniQue + lid => 'G', # newsGroup (or similar entity), just inside <> + dfpre => 'XDFPRE', + dfpost => 'XDFPOST', + dfblob => 'XDFPRE XDFPOST', ); +my $non_quoted_body = 'XNQ XDFN XDFA XDFB XDFHH XDFCTX XDFPRE XDFPOST'; my %prob_prefix = ( # for mairix compatibility s => 'S', m => 'XM', # 'mid:' (bool) is exact, 'm:' (prob) can do partial + l => 'XL', # 'lid:' (bool) is exact, 'l:' (prob) can do partial f => 'A', t => 'XTO', tc => 'XTO XCC', c => 'XCC', tcf => 'XTO XCC A', a => 'XTO XCC A', - b => 'XNQ XQUOT', - bs => 'XNQ XQUOT S', + b => $non_quoted_body . ' XQUOT', + bs => $non_quoted_body . ' XQUOT S', n => 'XFN', q => 'XQUOT', - nq => 'XNQ', + nq => $non_quoted_body, dfn => 'XDFN', dfa => 'XDFA', dfb => 'XDFB', dfhh => 'XDFHH', dfctx => 'XDFCTX', - dfpre => 'XDFPRE', - dfpost => 'XDFPOST', - dfblob => 'XDFPRE XDFPOST', # default: - '' => 'XM S A XNQ XQUOT XFN', + '' => 'XM S A XQUOT XFN ' . $non_quoted_body, ); # not documenting m: and mid: for now, the using the URLs works w/o Xapian +# not documenting lid: for now, either, it is probably redundant with l:, +# especially since we don't offer boolean searches for To/Cc/From +# headers, either our @HELP = ( 's:' => 'match within Subject e.g. s:"a quick brown fox"', 'd:' => < < 'match within message body, including text attachments', 'nq:' => 'match non-quoted text within message body', @@ -105,6 +170,7 @@ EOF 'f:' => 'match within the From header', 'a:' => 'match within the To, Cc, and From headers', 'tc:' => 'match within the To and Cc headers', + 'l:' => 'match contents of the List-Id header', 'bs:' => 'match within the Subject and body', 'dfn:' => 'match filename from diff', 'dfa:' => 'match diff removed (-) lines', @@ -117,137 +183,226 @@ EOF ); chomp @HELP; -my $mail_query = Search::Xapian::Query->new('T' . 'mail'); +sub xdir ($;$) { + my ($self, $rdonly) = @_; + if ($rdonly || !defined($self->{shard})) { + $self->{xpfx}; + } else { # v2 only: + "$self->{xpfx}/$self->{shard}"; + } +} -sub xdir { - my (undef, $git_dir) = @_; - "$git_dir/public-inbox/xapian" . SCHEMA_VERSION; +sub _xdb ($) { + my ($self) = @_; + my $dir = xdir($self, 1); + my ($xdb, $slow_phrase); + my $qpf = \($self->{qp_flags} ||= $QP_FLAGS); + if ($self->{ibx_ver} >= 2) { + my @xdb; + opendir(my $dh, $dir) or return; # not initialized yet + + # We need numeric sorting so shard[0] is first for reading + # Xapian metadata, if needed + for (sort { $a <=> $b } grep(/\A[0-9]+\z/, readdir($dh))) { + my $shard_dir = "$dir/$_"; + if (-d $shard_dir && -r _) { + push @xdb, $X{Database}->new($shard_dir); + $slow_phrase ||= -f "$shard_dir/iamchert"; + } else { # gaps from missing epochs throw off mdocid() + warn "E: $shard_dir missing or unreadable\n"; + return; + } + } + $self->{nshard} = scalar(@xdb); + $xdb = shift @xdb; + $xdb->add_database($_) for @xdb; + } else { + $slow_phrase = -f "$dir/iamchert"; + $xdb = $X{Database}->new($dir); + } + $$qpf |= FLAG_PHRASE() unless $slow_phrase; + $xdb; } -sub new { - my ($class, $git_dir, $altid) = @_; - my $dir = $class->xdir($git_dir); - my $db = Search::Xapian::Database->new($dir); - bless { xdb => $db, git_dir => $git_dir, altid => $altid }, $class; +# v2 Xapian docids don't conflict, so they're identical to +# NNTP article numbers and IMAP UIDs. +# https://trac.xapian.org/wiki/FAQ/MultiDatabaseDocumentID +sub mdocid { + my ($nshard, $mitem) = @_; + my $docid = $mitem->get_docid; + int(($docid - 1) / $nshard) + 1; } -sub reopen { $_[0]->{xdb}->reopen } +sub mset_to_artnums { + my ($self, $mset) = @_; + my $nshard = $self->{nshard} // 1; + [ map { mdocid($nshard, $_) } $mset->items ]; +} -# read-only -sub query { - my ($self, $query_string, $opts) = @_; - my $query; +sub xdb ($) { + my ($self) = @_; + $self->{xdb} ||= do { + load_xapian(); + _xdb($self); + }; +} - $opts ||= {}; - unless ($query_string eq '') { - $query = $self->qp->parse_query($query_string, QP_FLAGS); - $opts->{relevance} = 1 unless exists $opts->{relevance}; +sub xpfx_init ($) { + my ($self) = @_; + if ($self->{ibx_ver} == 1) { + $self->{xpfx} .= '/public-inbox/xapian' . SCHEMA_VERSION; + } else { + $self->{xpfx} .= '/xap'.SCHEMA_VERSION; } +} - _do_enquire($self, $query, $opts); +sub new { + my ($class, $ibx) = @_; + ref $ibx or die "BUG: expected PublicInbox::Inbox object: $ibx"; + my $self = bless { + xpfx => $ibx->{inboxdir}, # for xpfx_init + altid => $ibx->{altid}, + ibx_ver => $ibx->version, + }, $class; + xpfx_init($self); + my $dir = xdir($self, 1); + $self->{over_ro} = PublicInbox::Over->new("$dir/over.sqlite3"); + $self; } -sub get_thread { - my ($self, $mid, $opts) = @_; - my $smsg = eval { $self->lookup_message($mid) }; - - return { total => 0, msgs => [] } unless $smsg; - my $qtid = Search::Xapian::Query->new('G' . $smsg->thread_id); - my $path = $smsg->path; - if (defined $path && $path ne '') { - my $path = id_compress($smsg->path); - my $qsub = Search::Xapian::Query->new('XPATH' . $path); - $qtid = Search::Xapian::Query->new(OP_OR, $qtid, $qsub); +sub reopen { + my ($self) = @_; + if (my $xdb = $self->{xdb}) { + $xdb->reopen; } - $opts ||= {}; - $opts->{limit} ||= 1000; - - # always sort threads by timestamp, this makes life easier - # for the threading algorithm (in SearchThread.pm) - $opts->{asc} = 1; + $self; # make chaining easier +} - _do_enquire($self, $qtid, $opts); +# read-only +sub query { + my ($self, $query_string, $opts) = @_; + $opts ||= {}; + if ($query_string eq '' && !$opts->{mset}) { + $self->{over_ro}->recent($opts); + } else { + my $qp = $self->{qp} //= qparse_new($self); + my $qp_flags = $self->{qp_flags}; + my $query = $qp->parse_query($query_string, $qp_flags); + $opts->{relevance} = 1 unless exists $opts->{relevance}; + _do_enquire($self, $query, $opts); + } } sub retry_reopen { - my ($self, $cb) = @_; - my $ret; - for (1..10) { - eval { $ret = $cb->() }; - return $ret unless $@; + my ($self, $cb, $arg) = @_; + for my $i (1..10) { + if (wantarray) { + my @ret; + eval { @ret = $cb->($arg) }; + return @ret unless $@; + } else { + my $ret; + eval { $ret = $cb->($arg) }; + return $ret unless $@; + } # Exception: The revision being read has been discarded - # you should call Xapian::Database::reopen() - if (ref($@) eq 'Search::Xapian::DatabaseModifiedError') { + if (ref($@) =~ /\bDatabaseModifiedError\b/) { + warn "reopen try #$i on $@\n"; reopen($self); } else { + # let caller decide how to spew, because ExtMsg queries + # get wonky and trigger: + # "something terrible happened at .../Xapian/Enquire.pm" die; } } + die "Too many Xapian database modifications in progress\n"; } sub _do_enquire { my ($self, $query, $opts) = @_; - retry_reopen($self, sub { _enquire_once($self, $query, $opts) }); + retry_reopen($self, \&_enquire_once, [ $self, $query, $opts ]); } -sub _enquire_once { - my ($self, $query, $opts) = @_; - my $enquire = $self->enquire; - if (defined $query) { - $query = Search::Xapian::Query->new(OP_AND,$query,$mail_query); - } else { - $query = $mail_query; - } +# returns true if all docs have the THREADID value +sub has_threadid ($) { + my ($self) = @_; + (xdb($self)->get_metadata('has_threadid') // '') eq '1'; +} + +sub _enquire_once { # retry_reopen callback + my ($self, $query, $opts) = @{$_[0]}; + my $xdb = xdb($self); + my $enquire = $X{Enquire}->new($xdb); $enquire->set_query($query); $opts ||= {}; my $desc = !$opts->{asc}; - if ($opts->{relevance}) { + if (($opts->{mset} || 0) == 2) { # mset == 2: ORDER BY docid/UID + $enquire->set_docid_order($ENQ_ASCENDING); + $enquire->set_weighting_scheme($X{BoolWeight}->new); + } elsif ($opts->{relevance}) { $enquire->set_sort_by_relevance_then_value(TS, $desc); - } elsif ($opts->{num}) { - $enquire->set_sort_by_value(NUM, 0); } else { $enquire->set_sort_by_value_then_relevance(TS, $desc); } + + # `mairix -t / --threads' or JMAP collapseThreads + if ($opts->{thread} && has_threadid($self)) { + $enquire->set_collapse_key(THREADID); + } + my $offset = $opts->{offset} || 0; my $limit = $opts->{limit} || 50; my $mset = $enquire->get_mset($offset, $limit); return $mset if $opts->{mset}; - my @msgs = map { - PublicInbox::SearchMsg->load_doc($_->get_document); - } $mset->items; - - { total => $mset->get_matches_estimated, msgs => \@msgs } + my $nshard = $self->{nshard} // 1; + my $i = 0; + my %order = map { mdocid($nshard, $_) => ++$i } $mset->items; + my @msgs = sort { + $order{$a->{num}} <=> $order{$b->{num}} + } @{$self->{over_ro}->get_all(keys %order)}; + wantarray ? ($mset->get_matches_estimated, \@msgs) : \@msgs; } # read-write -sub stemmer { Search::Xapian::Stem->new($LANG) } +sub stemmer { $X{Stem}->new($LANG) } # read-only -sub qp { +sub qparse_new ($) { my ($self) = @_; - my $qp = $self->{query_parser}; - return $qp if $qp; - - # new parser - $qp = Search::Xapian::QueryParser->new; - $qp->set_default_op(OP_AND); - $qp->set_database($self->{xdb}); - $qp->set_stemmer($self->stemmer); - $qp->set_stemming_strategy(STEM_SOME); - $qp->add_valuerangeprocessor( - Search::Xapian::NumberValueRangeProcessor->new(YYYYMMDD, 'd:')); + my $xdb = xdb($self); + my $qp = $X{QueryParser}->new; + $qp->set_default_op(OP_AND()); + $qp->set_database($xdb); + $qp->set_stemmer(stemmer($self)); + $qp->set_stemming_strategy(STEM_SOME()); + my $cb = $qp->can('set_max_wildcard_expansion') // + $qp->can('set_max_expansion'); # Xapian 1.5.0+ + $cb->($qp, 100); + $cb = $qp->can('add_valuerangeprocessor') // + $qp->can('add_rangeprocessor'); # Xapian 1.5.0+ + $cb->($qp, $NVRP->new(YYYYMMDD, 'd:')); + $cb->($qp, $NVRP->new(DT, 'dt:')); + + # for IMAP, undocumented for WWW and may be split off go away + $cb->($qp, $NVRP->new(BYTES, 'bytes:')); + $cb->($qp, $NVRP->new(TS, 'ts:')); + $cb->($qp, $NVRP->new(UID, 'uid:')); while (my ($name, $prefix) = each %bool_pfx_external) { - $qp->add_boolean_prefix($name, $prefix); + $qp->add_boolean_prefix($name, $_) foreach split(/ /, $prefix); } # we do not actually create AltId objects, # just parse the spec to avoid the extra DB handles for now. if (my $altid = $self->{altid}) { - my $user_pfx = $self->{-user_pfx} ||= []; + my $user_pfx = $self->{-user_pfx} = []; for (@$altid) { # $_ = 'serial:gmane:/path/to/gmane.msgmap.sqlite3' + # note: Xapian supports multibyte UTF-8, /^[0-9]+$/, + # and '_' with prefixes matching \w+ /\Aserial:(\w+):/ or next; my $pfx = $1; push @$user_pfx, "$pfx:", <add_prefix($name, $_) foreach split(/ /, $prefix); } - - $self->{query_parser} = $qp; -} - -sub num_range_processor { - $_[0]->{nrp} ||= Search::Xapian::NumberValueRangeProcessor->new(NUM); -} - -# only used for NNTP server -sub query_xover { - my ($self, $beg, $end, $offset) = @_; - my $qp = Search::Xapian::QueryParser->new; - $qp->set_database($self->{xdb}); - $qp->add_valuerangeprocessor($self->num_range_processor); - my $query = $qp->parse_query("$beg..$end", QP_FLAGS); - - _do_enquire($self, $query, {num => 1, limit => 200, offset => $offset}); -} - -sub lookup_message { - my ($self, $mid) = @_; - $mid = mid_clean($mid); - - my $doc_id = $self->find_unique_doc_id('XMID' . $mid); - my $smsg; - if (defined $doc_id) { - # raises on error: - my $doc = $self->{xdb}->get_document($doc_id); - $smsg = PublicInbox::SearchMsg->wrap($doc, $mid); - $smsg->{doc_id} = $doc_id; - } - $smsg; -} - -sub lookup_mail { # no ghosts! - my ($self, $mid) = @_; - retry_reopen($self, sub { - my $smsg = lookup_message($self, $mid) or return; - $smsg->load_expand; - }); -} - -sub find_unique_doc_id { - my ($self, $termval) = @_; - - my ($begin, $end) = $self->find_doc_ids($termval); - - return undef if $begin->equal($end); # not found - - my $rv = $begin->get_docid; - - # sanity check - $begin->inc; - $begin->equal($end) or die "Term '$termval' is not unique\n"; - $rv; -} - -# returns begin and end PostingIterator -sub find_doc_ids { - my ($self, $termval) = @_; - my $db = $self->{xdb}; - - ($db->postlist_begin($termval), $db->postlist_end($termval)); -} - -# normalize subjects so they are suitable as pathnames for URLs -# XXX: consider for removal -sub subject_path { - my $subj = pop; - $subj = subject_normalized($subj); - $subj =~ s![^a-zA-Z0-9_\.~/\-]+!_!g; - lc($subj); -} - -sub subject_normalized { - my $subj = pop; - $subj =~ s/\A\s+//s; # no leading space - $subj =~ s/\s+\z//s; # no trailing space - $subj =~ s/\s+/ /gs; # no redundant spaces - $subj =~ s/\.+\z//; # no trailing '.' - $subj =~ s/$REPLY_RE//igo; # remove reply prefix - $subj; -} - -sub enquire { - my ($self) = @_; - $self->{enquire} ||= Search::Xapian::Enquire->new($self->{xdb}); + $qp; } sub help { my ($self) = @_; - $self->qp; # parse altids + $self->{qp} //= qparse_new($self); # parse altids my @ret = @HELP; if (my $user_pfx = $self->{-user_pfx}) { push @ret, @$user_pfx;