]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/Over.pm
over+msgmap: respect WAL journal_mode if set
[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 warnings;
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 disconnect {
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 connect { $_[0]->{dbh} //= $_[0]->dbh_new }
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 $dbh = $self->connect;
101         my $lim = (($opts->{limit} || 0) + 0) || DEFAULT_LIMIT;
102         $sql .= "LIMIT $lim";
103         my $msgs = $dbh->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) = @_;
111         do_get($self, <<'', {}, $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,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 = $self->connect;
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 = $self->connect;
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         my $msgs = do_get($self, <<"", $opts, @v);
249 SELECT ts,ds,ddd FROM over WHERE $s
250
251         return $msgs unless wantarray;
252
253         my $nr = $self->{dbh}->selectrow_array(<<'');
254 SELECT COUNT(num) FROM over WHERE num > 0
255
256         ($nr, $msgs);
257 }
258
259 sub get_art {
260         my ($self, $num) = @_;
261         # caching $sth ourselves is faster than prepare_cached
262         my $sth = $self->{-get_art} //= $self->connect->prepare(<<'');
263 SELECT num,tid,ds,ts,ddd FROM over WHERE num = ? LIMIT 1
264
265         $sth->execute($num);
266         my $smsg = $sth->fetchrow_hashref;
267         $smsg ? load_from_row($smsg) : undef;
268 }
269
270 sub next_by_mid {
271         my ($self, $mid, $id, $prev) = @_;
272         my $dbh = $self->connect;
273
274         unless (defined $$id) {
275                 my $sth = $dbh->prepare_cached(<<'', undef, 1);
276         SELECT id FROM msgid WHERE mid = ? LIMIT 1
277
278                 $sth->execute($mid);
279                 $$id = $sth->fetchrow_array;
280                 defined $$id or return;
281         }
282         my $sth = $dbh->prepare_cached(<<"", undef, 1);
283 SELECT num FROM id2num WHERE id = ? AND num > ?
284 ORDER BY num ASC LIMIT 1
285
286         $$prev ||= 0;
287         $sth->execute($$id, $$prev);
288         my $num = $sth->fetchrow_array or return;
289         $$prev = $num;
290         get_art($self, $num);
291 }
292
293 # IMAP search, this is limited by callers to UID_SLICE size (50K)
294 sub uid_range {
295         my ($self, $beg, $end, $sql) = @_;
296         my $dbh = $self->connect;
297         my $q = 'SELECT num FROM over WHERE num >= ? AND num <= ?';
298
299         # This is read-only, anyways; but caller should verify it's
300         # only sending \A[0-9]+\z for ds and ts column ranges
301         $q .= $$sql if $sql;
302         $q .= ' ORDER BY num ASC';
303         $dbh->selectcol_arrayref($q, undef, $beg, $end);
304 }
305
306 sub max {
307         my ($self) = @_;
308         my $sth = $self->connect->prepare_cached(<<'', undef, 1);
309 SELECT MAX(num) FROM over WHERE num > 0
310
311         $sth->execute;
312         $sth->fetchrow_array // 0;
313 }
314
315 sub imap_exists {
316         my ($self, $uid_base, $uid_end) = @_;
317         my $sth = $self->connect->prepare_cached(<<'', undef, 1);
318 SELECT COUNT(num) FROM over WHERE num > ? AND num <= ?
319
320         $sth->execute($uid_base, $uid_end);
321         $sth->fetchrow_array;
322 }
323
324 sub check_inodes {
325         my ($self) = @_;
326         my $dbh = $self->{dbh} or return;
327         my $f = $dbh->sqlite_db_filename;
328         if (my @st = stat($f)) { # did st_dev, st_ino change?
329                 my $st = pack('dd', $st[0], $st[1]);
330
331                 # don't actually reopen, just let {dbh} be recreated later
332                 disconnect($self) if $st ne ($self->{st} // $st);
333         } else {
334                 warn "W: stat $f: $!\n";
335         }
336 }
337
338 1;