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