]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/Msgmap.pm
839ddf7cadbbec6b85dde59de1334b55b5a721c7
[public-inbox.git] / lib / PublicInbox / Msgmap.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
4 # bidirectional Message-ID <-> Article Number mapping for the NNTP
5 # and web interfaces.  This is required for implementing stable article
6 # numbers for NNTP and allows prefix lookups for partial Message-IDs
7 # in case URLs get truncated from copy-n-paste errors by users.
8 #
9 # This is maintained by ::SearchIdx
10 package PublicInbox::Msgmap;
11 use strict;
12 use warnings;
13 use DBI;
14 use DBD::SQLite;
15 use File::Temp qw(tempfile);
16 use PublicInbox::Over;
17
18 sub new {
19         my ($class, $git_dir, $writable) = @_;
20         my $d = "$git_dir/public-inbox";
21         if ($writable && !-d $d && !mkdir $d) {
22                 my $err = $!;
23                 -d $d or die "$d not created: $err";
24         }
25         new_file($class, "$d/msgmap.sqlite3", $writable);
26 }
27
28 sub new_file {
29         my ($class, $f, $rw) = @_;
30         return if !$rw && !-r $f;
31
32         my $self = bless { filename => $f }, $class;
33         my $dbh = $self->{dbh} = PublicInbox::Over::dbh_new($self, $rw);
34         if ($rw) {
35                 # TRUNCATE reduces I/O compared to the default (DELETE)
36                 $dbh->do('PRAGMA journal_mode = TRUNCATE');
37
38                 $dbh->begin_work;
39                 create_tables($dbh);
40                 $self->created_at(time) unless $self->created_at;
41
42                 my $max = $self->max // 0;
43                 $self->num_highwater($max);
44                 $dbh->commit;
45         }
46         $self;
47 }
48
49 # used to keep track of used numeric mappings for v2 reindex
50 sub tmp_clone {
51         my ($self) = @_;
52         my ($fh, $fn) = tempfile('msgmap-XXXXXXXX', EXLOCK => 0, TMPDIR => 1);
53         my $tmp;
54         if ($self->{dbh}->can('sqlite_backup_to_dbh')) {
55                 $tmp = ref($self)->new_file($fn, 2);
56                 $tmp->{dbh}->do('PRAGMA journal_mode = MEMORY');
57                 $self->{dbh}->sqlite_backup_to_dbh($tmp->{dbh});
58         } else { # DBD::SQLite <= 1.61_01
59                 $self->{dbh}->sqlite_backup_to_file($fn);
60                 $tmp = ref($self)->new_file($fn, 2);
61                 $tmp->{dbh}->do('PRAGMA journal_mode = MEMORY');
62         }
63         $tmp->{pid} = $$;
64         $tmp;
65 }
66
67 # n.b. invoked directly by scripts/xhdr-num2mid
68 sub meta_accessor {
69         my ($self, $key, $value) = @_;
70
71         my $sql = 'SELECT val FROM meta WHERE key = ? LIMIT 1';
72         my $dbh = $self->{dbh};
73         my $prev;
74         defined $value or return $dbh->selectrow_array($sql, undef, $key);
75
76         $prev = $dbh->selectrow_array($sql, undef, $key);
77
78         if (defined $prev) {
79                 $sql = 'UPDATE meta SET val = ? WHERE key = ?';
80                 $dbh->do($sql, undef, $value, $key);
81         } else {
82                 $sql = 'INSERT INTO meta (key,val) VALUES (?,?)';
83                 $dbh->do($sql, undef, $key, $value);
84         }
85         $prev;
86 }
87
88 sub last_commit {
89         my ($self, $commit) = @_;
90         $self->meta_accessor('last_commit', $commit);
91 }
92
93 # v2 uses this to keep track of how up-to-date Xapian is
94 # old versions may be automatically GC'ed away in the future,
95 # but it's a trivial amount of storage.
96 sub last_commit_xap {
97         my ($self, $version, $i, $commit) = @_;
98         $self->meta_accessor("last_xap$version-$i", $commit);
99 }
100
101 sub created_at {
102         my ($self, $second) = @_;
103         $self->meta_accessor('created_at', $second);
104 }
105
106 sub num_highwater {
107         my ($self, $num) = @_;
108         my $high = $self->{num_highwater} ||=
109             $self->meta_accessor('num_highwater');
110         if (defined($num) && (!defined($high) || ($num > $high))) {
111                 $self->{num_highwater} = $num;
112                 $self->meta_accessor('num_highwater', $num);
113         }
114         $self->{num_highwater};
115 }
116
117 sub mid_insert {
118         my ($self, $mid) = @_;
119         my $dbh = $self->{dbh};
120         my $sth = $dbh->prepare_cached(<<'');
121 INSERT INTO msgmap (mid) VALUES (?)
122
123         return unless eval { $sth->execute($mid) };
124         my $num = $dbh->last_insert_id(undef, undef, 'msgmap', 'num');
125         $self->num_highwater($num) if defined($num);
126         $num;
127 }
128
129 sub mid_for {
130         my ($self, $num) = @_;
131         my $dbh = $self->{dbh};
132         my $sth = $self->{mid_for} ||=
133                 $dbh->prepare('SELECT mid FROM msgmap WHERE num = ? LIMIT 1');
134         $sth->bind_param(1, $num);
135         $sth->execute;
136         $sth->fetchrow_array;
137 }
138
139 sub num_for {
140         my ($self, $mid) = @_;
141         my $dbh = $self->{dbh};
142         my $sth = $self->{num_for} ||=
143                 $dbh->prepare('SELECT num FROM msgmap WHERE mid = ? LIMIT 1');
144         $sth->bind_param(1, $mid);
145         $sth->execute;
146         $sth->fetchrow_array;
147 }
148
149 sub max {
150         my $sth = $_[0]->{dbh}->prepare_cached('SELECT MAX(num) FROM msgmap',
151                                                 undef, 1);
152         $sth->execute;
153         $sth->fetchrow_array;
154 }
155
156 sub minmax {
157         # breaking MIN and MAX into separate queries speeds up from 250ms
158         # to around 700us with 2.7million messages.
159         my $sth = $_[0]->{dbh}->prepare_cached('SELECT MIN(num) FROM msgmap',
160                                                 undef, 1);
161         $sth->execute;
162         ($sth->fetchrow_array, max($_[0]));
163 }
164
165 sub mid_delete {
166         my ($self, $mid) = @_;
167         my $dbh = $self->{dbh};
168         my $sth = $dbh->prepare('DELETE FROM msgmap WHERE mid = ?');
169         $sth->bind_param(1, $mid);
170         $sth->execute;
171 }
172
173 sub num_delete {
174         my ($self, $num) = @_;
175         my $dbh = $self->{dbh};
176         my $sth = $dbh->prepare('DELETE FROM msgmap WHERE num = ?');
177         $sth->bind_param(1, $num);
178         $sth->execute;
179 }
180
181 sub create_tables {
182         my ($dbh) = @_;
183         my $e;
184
185         $e = eval { $dbh->selectrow_array('EXPLAIN SELECT * FROM msgmap;') };
186         defined $e or $dbh->do('CREATE TABLE msgmap (' .
187                         'num INTEGER PRIMARY KEY AUTOINCREMENT, '.
188                         'mid VARCHAR(1000) NOT NULL, ' .
189                         'UNIQUE (mid) )');
190
191         $e = eval { $dbh->selectrow_array('EXPLAIN SELECT * FROM meta') };
192         defined $e or $dbh->do('CREATE TABLE meta (' .
193                         'key VARCHAR(32) PRIMARY KEY, '.
194                         'val VARCHAR(255) NOT NULL)');
195 }
196
197 # used by NNTP.pm
198 sub ids_after {
199         my ($self, $num) = @_;
200         my $ids = $self->{dbh}->selectcol_arrayref(<<'', undef, $$num);
201 SELECT num FROM msgmap WHERE num > ?
202 ORDER BY num ASC LIMIT 1000
203
204         $$num = $ids->[-1] if @$ids;
205         $ids;
206 }
207
208 sub msg_range {
209         my ($self, $beg, $end, $cols) = @_;
210         $cols //= 'num,mid';
211         my $dbh = $self->{dbh};
212         my $attr = { Columns => [] };
213         my $mids = $dbh->selectall_arrayref(<<"", $attr, $$beg, $end);
214 SELECT $cols FROM msgmap WHERE num >= ? AND num <= ?
215 ORDER BY num ASC LIMIT 1000
216
217         $$beg = $mids->[-1]->[0] + 1 if @$mids;
218         $mids
219 }
220
221 # only used for mapping external serial numbers (e.g. articles from gmane)
222 # see scripts/xhdr-num2mid or PublicInbox::Filter::RubyLang for usage
223 sub mid_set {
224         my ($self, $num, $mid) = @_;
225         my $sth = $self->{mid_set} ||= do {
226                 $self->{dbh}->prepare(
227                         'INSERT OR IGNORE INTO msgmap (num,mid) VALUES (?,?)');
228         };
229         my $result = $sth->execute($num, $mid);
230         $self->num_highwater($num) if (defined($result) && $result == 1);
231         $result;
232 }
233
234 sub DESTROY {
235         my ($self) = @_;
236         my $dbh = $self->{dbh} or return;
237         if (($self->{pid} // 0) == $$) {
238                 my $f = $dbh->sqlite_db_filename;
239                 unlink $f or warn "failed to unlink $f: $!\n";
240         }
241 }
242
243 sub atfork_parent {
244         my ($self) = @_;
245         $self->{pid} or die 'BUG: not a temporary clone';
246         $self->{dbh} and die 'BUG: tmp_clone dbh not prepared for parent';
247         defined($self->{filename}) or die 'BUG: {filename} not defined';
248         $self->{dbh} = PublicInbox::Over::dbh_new($self, 2);
249 }
250
251 sub atfork_prepare {
252         my ($self) = @_;
253         my $pid = $self->{pid} or die 'BUG: not a temporary clone';
254         $pid == $$ or die "BUG: atfork_prepare not called by $pid";
255         my $dbh = $self->{dbh} or die 'BUG: temporary clone not open';
256
257         # must clobber prepared statements
258         %$self = (filename => $dbh->sqlite_db_filename, pid => $pid);
259 }
260
261 sub skip_artnum {
262         my ($self, $skip_artnum) = @_;
263         return meta_accessor($self, 'skip_artnum') if !defined($skip_artnum);
264
265         my $cur = num_highwater($self) // 0;
266         if ($skip_artnum < $cur) {
267                 die "E: current article number $cur ",
268                         "exceeds --skip-artnum=$skip_artnum\n";
269         } else {
270                 my $ok;
271                 for (1..10) {
272                         my $mid = 'skip'.rand.'@'.rand.'.example.com';
273                         $ok = mid_set($self, $skip_artnum, $mid);
274                         if ($ok) {
275                                 mid_delete($self, $mid);
276                                 last;
277                         }
278                 }
279                 $ok or die '--skip-artnum failed';
280
281                 # in the future, the indexer may use this value for
282                 # new messages in old epochs
283                 meta_accessor($self, 'skip_artnum', $skip_artnum);
284         }
285 }
286
287 sub check_inodes {
288         my ($self) = @_;
289         # no filename if in-:memory:
290         my $f = $self->{dbh}->sqlite_db_filename // return;
291         if (my @st = stat($f)) { # did st_dev, st_ino change?
292                 my $st = pack('dd', $st[0], $st[1]);
293                 if ($st ne ($self->{st} // $st)) {
294                         my $tmp = eval { ref($self)->new_file($f) };
295                         if ($@) {
296                                 warn "E: DBI->connect($f): $@\n";
297                         } else {
298                                 %$self = %$tmp;
299                         }
300                 }
301         } else {
302                 warn "W: stat $f: $!\n";
303         }
304 }
305
306 1;