]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/Over.pm
imap+nntp: share COMPRESS implementation
[public-inbox.git] / lib / PublicInbox / Over.pm
1 # Copyright (C) all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # for XOVER, OVER in NNTP, and feeds/homepage/threads in PSGI
5 # Unlike Msgmap, this is an _UNSTABLE_ database which can be
6 # tweaked/updated over time and rebuilt.
7 package PublicInbox::Over;
8 use strict;
9 use v5.10.1;
10 use DBI qw(:sql_types); # SQL_BLOB
11 use DBD::SQLite;
12 use PublicInbox::Smsg;
13 use Compress::Zlib qw(uncompress);
14 use constant DEFAULT_LIMIT => 1000;
15
16 sub dbh_new {
17         my ($self, $rw) = @_;
18         my $f = delete $self->{filename};
19         if (!-s $f) { # SQLite defaults mode to 0644, we want 0666
20                 if ($rw) {
21                         require PublicInbox::Syscall;
22                         my ($dir) = ($f =~ m!(.+)/[^/]+\z!);
23                         PublicInbox::Syscall::nodatacow_dir($dir);
24                         open my $fh, '+>>', $f or die "failed to open $f: $!";
25                 } else {
26                         $self->{filename} = $f; # die on stat() below:
27                 }
28         }
29         my (@st, $st, $dbh);
30         my $tries = 0;
31         do {
32                 @st = stat($f) or die "failed to stat $f: $!";
33                 $st = pack('dd', $st[0], $st[1]); # 0: dev, 1: inode
34                 $dbh = DBI->connect("dbi:SQLite:dbname=$f",'','', {
35                         AutoCommit => 1,
36                         RaiseError => 1,
37                         PrintError => 0,
38                         ReadOnly => !$rw,
39                         sqlite_use_immediate_transaction => 1,
40                 });
41                 $self->{st} = $st;
42                 @st = stat($f) or die "failed to stat $f: $!";
43                 $st = pack('dd', $st[0], $st[1]);
44         } while ($st ne $self->{st} && $tries++ < 3);
45         warn "W: $f: .st_dev, .st_ino unstable\n" if $st ne $self->{st};
46
47         if ($rw) {
48                 # TRUNCATE reduces I/O compared to the default (DELETE).
49                 #
50                 # Do not use WAL by default since we expect the case
51                 # where any users may read via read-only daemons
52                 # (-httpd/-imapd/-nntpd); but only a single user has
53                 # write permissions for -watch/-mda.
54                 #
55                 # Read-only WAL support in SQLite 3.22.0 (2018-01-22)
56                 # doesn't do what we need: it is only intended for
57                 # immutable read-only media (e.g. CD-ROM) and not
58                 # usable for our use case described above.
59                 #
60                 # If an admin is willing to give read-only daemons R/W
61                 # permissions; they can enable WAL manually and we will
62                 # respect that by not clobbering it.
63                 my $jm = $dbh->selectrow_array('PRAGMA journal_mode');
64                 $dbh->do('PRAGMA journal_mode = TRUNCATE') if $jm ne 'wal';
65
66                 $dbh->do('PRAGMA synchronous = OFF') if $rw > 1;
67         }
68         $dbh;
69 }
70
71 sub new {
72         my ($class, $f) = @_;
73         bless { filename => $f }, $class;
74 }
75
76 sub dbh_close {
77         my ($self) = @_;
78         if (my $dbh = delete $self->{dbh}) {
79                 delete $self->{-get_art};
80                 $self->{filename} = $dbh->sqlite_db_filename;
81         }
82 }
83
84 sub dbh ($) { $_[0]->{dbh} //= $_[0]->dbh_new } # dbh_new may be subclassed
85
86 sub load_from_row ($;$) {
87         my ($smsg, $cull) = @_;
88         bless $smsg, 'PublicInbox::Smsg';
89         if (defined(my $data = delete $smsg->{ddd})) {
90                 $data = uncompress($data);
91                 PublicInbox::Smsg::load_from_data($smsg, $data);
92
93                 # saves over 600K for 1000+ message threads
94                 PublicInbox::Smsg::psgi_cull($smsg) if $cull;
95         }
96         $smsg
97 }
98
99 sub do_get {
100         my ($self, $sql, $opts, @args) = @_;
101         my $lim = (($opts->{limit} || 0) + 0) || DEFAULT_LIMIT;
102         $sql .= "LIMIT $lim";
103         my $msgs = dbh($self)->selectall_arrayref($sql, { Slice => {} }, @args);
104         my $cull = $opts->{cull};
105         load_from_row($_, $cull) for @$msgs;
106         $msgs
107 }
108
109 sub query_xover {
110         my ($self, $beg, $end, $opt) = @_;
111         do_get($self, <<'', $opt, $beg, $end);
112 SELECT num,ts,ds,ddd FROM over WHERE num >= ? AND num <= ?
113 ORDER BY num ASC
114
115 }
116
117 sub query_ts {
118         my ($self, $ts, $prev) = @_;
119         do_get($self, <<'', {}, $ts, $prev);
120 SELECT num,ddd FROM over WHERE ts >= ? AND num > ?
121 ORDER BY num ASC
122
123 }
124
125 sub get_all {
126         my $self = shift;
127         my $nr = scalar(@_) or return [];
128         my $in = '?' . (',?' x ($nr - 1));
129         do_get($self, <<"", { cull => 1, limit => $nr }, @_);
130 SELECT num,ts,ds,ddd FROM over WHERE num IN ($in)
131
132 }
133
134 sub nothing () { wantarray ? (0, []) : [] };
135
136 sub get_thread {
137         my ($self, $mid, $prev) = @_;
138         my $dbh = dbh($self);
139         my $opts = { cull => 1 };
140
141         my $id = $dbh->selectrow_array(<<'', undef, $mid);
142 SELECT id FROM msgid WHERE mid = ? LIMIT 1
143
144         defined $id or return nothing;
145
146         my $num = $dbh->selectrow_array(<<'', undef, $id);
147 SELECT num FROM id2num WHERE id = ? AND num > 0
148 ORDER BY num ASC LIMIT 1
149
150         defined $num or return nothing;
151
152         my ($tid, $sid) = $dbh->selectrow_array(<<'', undef, $num);
153 SELECT tid,sid FROM over WHERE num = ? LIMIT 1
154
155         defined $tid or return nothing; # $sid may be undef
156
157         my $cond_all = '(tid = ? OR sid = ?) AND num > ?';
158         my $sort_col = 'ds';
159         $num = 0;
160         if ($prev) { # mboxrd stream, only
161                 $num = $prev->{num} || 0;
162                 $sort_col = 'num';
163         }
164
165         my $cols = 'num,ts,ds,ddd';
166         unless (wantarray) {
167                 return do_get($self, <<"", $opts, $tid, $sid, $num);
168 SELECT $cols FROM over WHERE $cond_all
169 ORDER BY $sort_col ASC
170
171         }
172
173         # HTML view always wants an array and never uses $prev,
174         # but the mbox stream never wants an array and always has $prev
175         die '$prev not supported with wantarray' if $prev;
176         my $nr = $dbh->selectrow_array(<<"", undef, $tid, $sid, $num);
177 SELECT COUNT(num) FROM over WHERE $cond_all
178
179         # giant thread, prioritize strict (tid) matches and throw
180         # in the loose (sid) matches at the end
181         my $msgs = do_get($self, <<"", $opts, $tid, $num);
182 SELECT $cols FROM over WHERE tid = ? AND num > ?
183 ORDER BY $sort_col ASC
184
185         # do we have room for loose matches? get the most recent ones, first:
186         my $lim = DEFAULT_LIMIT - scalar(@$msgs);
187         if ($lim > 0) {
188                 $opts->{limit} = $lim;
189                 my $loose = do_get($self, <<"", $opts, $tid, $sid, $num);
190 SELECT $cols FROM over WHERE tid != ? AND sid = ? AND num > ?
191 ORDER BY $sort_col DESC
192
193                 # TODO separate strict and loose matches here once --reindex
194                 # is fixed to preserve `tid' properly
195                 push @$msgs, @$loose;
196         }
197         ($nr, $msgs);
198 }
199
200 # strict `tid' matches, only, for thread-expanded mbox.gz search results
201 # and future CLI interface
202 # returns true if we have IDs, undef if not
203 sub expand_thread {
204         my ($self, $ctx) = @_;
205         my $dbh = dbh($self);
206         do {
207                 defined(my $num = $ctx->{ids}->[0]) or return;
208                 my ($tid) = $dbh->selectrow_array(<<'', undef, $num);
209 SELECT tid FROM over WHERE num = ?
210
211                 if (defined($tid)) {
212                         my $sql = <<'';
213 SELECT num FROM over WHERE tid = ? AND num > ?
214 ORDER BY num ASC LIMIT 1000
215
216                         my $xids = $dbh->selectcol_arrayref($sql, undef, $tid,
217                                                         $ctx->{prev} // 0);
218                         if (scalar(@$xids)) {
219                                 $ctx->{prev} = $xids->[-1];
220                                 $ctx->{xids} = $xids;
221                                 return 1; # success
222                         }
223                 }
224                 $ctx->{prev} = 0;
225                 shift @{$ctx->{ids}};
226         } while (1);
227 }
228
229 sub recent {
230         my ($self, $opts, $after, $before) = @_;
231         my ($s, @v);
232         if (defined($before)) {
233                 if (defined($after)) {
234                         $s = '+num > 0 AND ts >= ? AND ts <= ? ORDER BY ts DESC';
235                         @v = ($after, $before);
236                 } else {
237                         $s = '+num > 0 AND ts <= ? ORDER BY ts DESC';
238                         @v = ($before);
239                 }
240         } else {
241                 if (defined($after)) {
242                         $s = '+num > 0 AND ts >= ? ORDER BY ts ASC';
243                         @v = ($after);
244                 } else {
245                         $s = '+num > 0 ORDER BY ts DESC';
246                 }
247         }
248         do_get($self, <<"", $opts, @v);
249 SELECT ts,ds,ddd FROM over WHERE $s
250
251 }
252
253 sub get_art {
254         my ($self, $num) = @_;
255         # caching $sth ourselves is faster than prepare_cached
256         my $sth = $self->{-get_art} //= dbh($self)->prepare(<<'');
257 SELECT num,tid,ds,ts,ddd FROM over WHERE num = ? LIMIT 1
258
259         $sth->execute($num);
260         my $smsg = $sth->fetchrow_hashref;
261         $smsg ? load_from_row($smsg) : undef;
262 }
263
264 sub get_xref3 {
265         my ($self, $num, $raw) = @_;
266         my $dbh = dbh($self);
267         my $sth = $dbh->prepare_cached(<<'', undef, 1);
268 SELECT ibx_id,xnum,oidbin FROM xref3 WHERE docid = ? ORDER BY ibx_id,xnum ASC
269
270         $sth->execute($num);
271         my $rows = $sth->fetchall_arrayref;
272         return $rows if $raw;
273         my $eidx_key_sth = $dbh->prepare_cached(<<'', undef, 1);
274 SELECT eidx_key FROM inboxes WHERE ibx_id = ?
275
276         [ map {
277                 my $r = $_;
278                 $eidx_key_sth->execute($r->[0]);
279                 my $eidx_key = $eidx_key_sth->fetchrow_array;
280                 $eidx_key //= "missing://ibx_id=$r->[0]";
281                 "$eidx_key:$r->[1]:".unpack('H*', $r->[2]);
282         } @$rows ];
283 }
284
285 sub next_by_mid {
286         my ($self, $mid, $id, $prev) = @_;
287         my $dbh = dbh($self);
288
289         unless (defined $$id) {
290                 my $sth = $dbh->prepare_cached(<<'', undef, 1);
291         SELECT id FROM msgid WHERE mid = ? LIMIT 1
292
293                 $sth->execute($mid);
294                 $$id = $sth->fetchrow_array;
295                 defined $$id or return;
296         }
297         my $sth = $dbh->prepare_cached(<<"", undef, 1);
298 SELECT num FROM id2num WHERE id = ? AND num > ?
299 ORDER BY num ASC LIMIT 1
300
301         $$prev ||= 0;
302         $sth->execute($$id, $$prev);
303         my $num = $sth->fetchrow_array or return;
304         $$prev = $num;
305         get_art($self, $num);
306 }
307
308 # IMAP search, this is limited by callers to UID_SLICE size (50K)
309 sub uid_range {
310         my ($self, $beg, $end, $sql) = @_;
311         my $dbh = dbh($self);
312         my $q = 'SELECT num FROM over WHERE num >= ? AND num <= ?';
313
314         # This is read-only, anyways; but caller should verify it's
315         # only sending \A[0-9]+\z for ds and ts column ranges
316         $q .= $$sql if $sql;
317         $q .= ' ORDER BY num ASC';
318         $dbh->selectcol_arrayref($q, undef, $beg, $end);
319 }
320
321 sub max {
322         my ($self) = @_;
323         my $sth = dbh($self)->prepare_cached(<<'', undef, 1);
324 SELECT MAX(num) FROM over WHERE num > 0
325
326         $sth->execute;
327         $sth->fetchrow_array // 0;
328 }
329
330 sub imap_exists {
331         my ($self, $uid_base, $uid_end) = @_;
332         my $sth = dbh($self)->prepare_cached(<<'', undef, 1);
333 SELECT COUNT(num) FROM over WHERE num > ? AND num <= ?
334
335         $sth->execute($uid_base, $uid_end);
336         $sth->fetchrow_array;
337 }
338
339 sub check_inodes {
340         my ($self) = @_;
341         my $dbh = $self->{dbh} or return;
342         my $f = $dbh->sqlite_db_filename;
343         if (my @st = stat($f)) { # did st_dev, st_ino change?
344                 my $st = pack('dd', $st[0], $st[1]);
345
346                 # don't actually reopen, just let {dbh} be recreated later
347                 dbh_close($self) if $st ne ($self->{st} // $st);
348         } else {
349                 warn "W: stat $f: $!\n";
350         }
351 }
352
353 sub oidbin_exists {
354         my ($self, $oidbin) = @_;
355         if (wantarray) {
356                 my $sth = $self->dbh->prepare_cached(<<'', undef, 1);
357 SELECT docid FROM xref3 WHERE oidbin = ? ORDER BY docid ASC
358
359                 $sth->bind_param(1, $oidbin, SQL_BLOB);
360                 $sth->execute;
361                 my $tmp = $sth->fetchall_arrayref;
362                 map { $_->[0] } @$tmp;
363         } else {
364                 my $sth = $self->dbh->prepare_cached(<<'', undef, 1);
365 SELECT COUNT(*) FROM xref3 WHERE oidbin = ?
366
367                 $sth->bind_param(1, $oidbin, SQL_BLOB);
368                 $sth->execute;
369                 $sth->fetchrow_array;
370         }
371 }
372
373 sub blob_exists { oidbin_exists($_[0], pack('H*', $_[1])) }
374
375 # used by NNTP.pm
376 sub ids_after {
377         my ($self, $num) = @_;
378         my $ids = dbh($self)->selectcol_arrayref(<<'', undef, $$num);
379 SELECT num FROM over WHERE num > ?
380 ORDER BY num ASC LIMIT 1000
381
382         $$num = $ids->[-1] if @$ids;
383         $ids;
384 }
385
386 1;