]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/Msgmap.pm
msgmap: speed up minmax with separate queries
[public-inbox.git] / lib / PublicInbox / Msgmap.pm
1 # Copyright (C) 2015-2018 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
17 sub new {
18         my ($class, $git_dir, $writable) = @_;
19         my $d = "$git_dir/public-inbox";
20         if ($writable && !-d $d && !mkdir $d) {
21                 my $err = $!;
22                 -d $d or die "$d not created: $err";
23         }
24         new_file($class, "$d/msgmap.sqlite3", $writable);
25 }
26
27 sub dbh_new {
28         my ($f, $writable) = @_;
29         my $dbh = DBI->connect("dbi:SQLite:dbname=$f",'','', {
30                 AutoCommit => 1,
31                 RaiseError => 1,
32                 PrintError => 0,
33                 ReadOnly => !$writable,
34                 sqlite_use_immediate_transaction => 1,
35         });
36         $dbh->do('PRAGMA case_sensitive_like = ON');
37         $dbh;
38 }
39
40 sub new_file {
41         my ($class, $f, $writable) = @_;
42         return if !$writable && !-r $f;
43
44         my $dbh = dbh_new($f, $writable);
45         my $self = bless { dbh => $dbh }, $class;
46
47         if ($writable) {
48                 create_tables($dbh);
49                 $dbh->begin_work;
50                 $self->created_at(time) unless $self->created_at;
51                 $dbh->commit;
52         }
53         $self;
54 }
55
56 # used to keep track of used numeric mappings for v2 reindex
57 sub tmp_clone {
58         my ($self) = @_;
59         my ($fh, $fn) = tempfile('msgmap-XXXXXXXX', EXLOCK => 0, TMPDIR => 1);
60         $self->{dbh}->sqlite_backup_to_file($fn);
61         my $tmp = ref($self)->new_file($fn, 1);
62         $tmp->{dbh}->do('PRAGMA synchronous = OFF');
63         $tmp->{tmp_name} = $fn; # SQLite won't work if unlinked, apparently
64         $tmp->{pid} = $$;
65         close $fh or die "failed to close $fn: $!";
66         $tmp;
67 }
68
69 # n.b. invoked directly by scripts/xhdr-num2mid
70 sub meta_accessor {
71         my ($self, $key, $value) = @_;
72
73         my $sql = 'SELECT val FROM meta WHERE key = ? LIMIT 1';
74         my $dbh = $self->{dbh};
75         my $prev;
76         defined $value or return $dbh->selectrow_array($sql, undef, $key);
77
78         $prev = $dbh->selectrow_array($sql, undef, $key);
79
80         if (defined $prev) {
81                 $sql = 'UPDATE meta SET val = ? WHERE key = ? LIMIT 1';
82                 $dbh->do($sql, undef, $value, $key);
83         } else {
84                 $sql = 'INSERT INTO meta (key,val) VALUES (?,?)';
85                 $dbh->do($sql, undef, $key, $value);
86         }
87         $prev;
88 }
89
90 sub last_commit {
91         my ($self, $commit) = @_;
92         $self->meta_accessor('last_commit', $commit);
93 }
94
95 # v2 uses this to keep track of how up-to-date Xapian is
96 # old versions may be automatically GC'ed away in the future,
97 # but it's a trivial amount of storage.
98 sub last_commit_xap {
99         my ($self, $version, $i, $commit) = @_;
100         $self->meta_accessor("last_xap$version-$i", $commit);
101 }
102
103 sub created_at {
104         my ($self, $second) = @_;
105         $self->meta_accessor('created_at', $second);
106 }
107
108 sub mid_insert {
109         my ($self, $mid) = @_;
110         my $dbh = $self->{dbh};
111         my $sql = 'INSERT OR IGNORE INTO msgmap (mid) VALUES (?)';
112         my $sth = $self->{mid_insert} ||= $dbh->prepare($sql);
113         $sth->bind_param(1, $mid);
114         return if $sth->execute == 0;
115         $dbh->last_insert_id(undef, undef, 'msgmap', 'num');
116 }
117
118 sub mid_for {
119         my ($self, $num) = @_;
120         my $dbh = $self->{dbh};
121         my $sth = $self->{mid_for} ||=
122                 $dbh->prepare('SELECT mid FROM msgmap WHERE num = ? LIMIT 1');
123         $sth->bind_param(1, $num);
124         $sth->execute;
125         $sth->fetchrow_array;
126 }
127
128 sub num_for {
129         my ($self, $mid) = @_;
130         my $dbh = $self->{dbh};
131         my $sth = $self->{num_for} ||=
132                 $dbh->prepare('SELECT num FROM msgmap WHERE mid = ? LIMIT 1');
133         $sth->bind_param(1, $mid);
134         $sth->execute;
135         $sth->fetchrow_array;
136 }
137
138 sub minmax {
139         my ($self) = @_;
140         my $dbh = $self->{dbh};
141         # breaking MIN and MAX into separate queries speeds up from 250ms
142         # to around 700us with 2.7million messages.
143         my $sth = $dbh->prepare_cached('SELECT MIN(num) FROM msgmap', undef, 1);
144         $sth->execute;
145         my $min = $sth->fetchrow_array;
146         $sth = $dbh->prepare_cached('SELECT MAX(num) FROM msgmap', undef, 1);
147         $sth->execute;
148         ($min, $sth->fetchrow_array);
149 }
150
151 sub mid_prefixes {
152         my ($self, $pfx, $limit) = @_;
153
154         die "No prefix given" unless (defined $pfx && $pfx ne '');
155         $pfx =~ s/([%_])/\\$1/g;
156         $pfx .= '%';
157
158         $limit ||= 100;
159         $limit += 0; # force to integer
160         $limit ||= 100;
161
162         $self->{dbh}->selectcol_arrayref('SELECT mid FROM msgmap ' .
163                                          'WHERE mid LIKE ? ESCAPE ? ' .
164                                          "ORDER BY num DESC LIMIT $limit",
165                                          undef, $pfx, '\\');
166 }
167
168 sub mid_delete {
169         my ($self, $mid) = @_;
170         my $dbh = $self->{dbh};
171         my $sth = $dbh->prepare('DELETE FROM msgmap WHERE mid = ?');
172         $sth->bind_param(1, $mid);
173         $sth->execute;
174 }
175
176 sub num_delete {
177         my ($self, $num) = @_;
178         my $dbh = $self->{dbh};
179         my $sth = $dbh->prepare('DELETE FROM msgmap WHERE num = ?');
180         $sth->bind_param(1, $num);
181         $sth->execute;
182 }
183
184 sub create_tables {
185         my ($dbh) = @_;
186         my $e;
187
188         $e = eval { $dbh->selectrow_array('EXPLAIN SELECT * FROM msgmap;') };
189         defined $e or $dbh->do('CREATE TABLE msgmap (' .
190                         'num INTEGER PRIMARY KEY AUTOINCREMENT, '.
191                         'mid VARCHAR(1000) NOT NULL, ' .
192                         'UNIQUE (mid) )');
193
194         $e = eval { $dbh->selectrow_array('EXPLAIN SELECT * FROM meta') };
195         defined $e or $dbh->do('CREATE TABLE meta (' .
196                         'key VARCHAR(32) PRIMARY KEY, '.
197                         'val VARCHAR(255) NOT NULL)');
198 }
199
200 # used by NNTP.pm
201 sub ids_after {
202         my ($self, $num) = @_;
203         my $ids = $self->{dbh}->selectcol_arrayref(<<'', undef, $$num);
204 SELECT num FROM msgmap WHERE num > ?
205 ORDER BY num ASC LIMIT 1000
206
207         $$num = $ids->[-1] if @$ids;
208         $ids;
209 }
210
211 sub msg_range {
212         my ($self, $beg, $end) = @_;
213         my $dbh = $self->{dbh};
214         my $attr = { Columns => [] };
215         my $mids = $dbh->selectall_arrayref(<<'', $attr, $$beg, $end);
216 SELECT num,mid FROM msgmap WHERE num >= ? AND num <= ?
217 ORDER BY num ASC
218
219         $$beg = $mids->[-1]->[0] + 1 if @$mids;
220         $mids
221 }
222
223 # only used for mapping external serial numbers (e.g. articles from gmane)
224 # see scripts/xhdr-num2mid or PublicInbox::Filter::RubyLang for usage
225 sub mid_set {
226         my ($self, $num, $mid) = @_;
227         my $sth = $self->{mid_set} ||= do {
228                 $self->{dbh}->prepare(
229                         'INSERT OR IGNORE INTO msgmap (num,mid) VALUES (?,?)');
230         };
231         $sth->execute($num, $mid);
232 }
233
234 sub DESTROY {
235         my ($self) = @_;
236         delete $self->{dbh};
237         my $f = delete $self->{tmp_name};
238         if (defined $f && $self->{pid} == $$) {
239                 unlink $f or warn "failed to unlink $f: $!\n";
240         }
241 }
242
243 sub atfork_parent {
244         my ($self) = @_;
245         my $f = $self->{tmp_name} or die "not a temporary clone\n";
246         delete $self->{dbh} and die "tmp_clone dbh not prepared for parent";
247         my $dbh = $self->{dbh} = dbh_new($f, 1);
248         $dbh->do('PRAGMA synchronous = OFF');
249 }
250
251 sub atfork_prepare {
252         my ($self) = @_;
253         my $f = $self->{tmp_name} or die "not a temporary clone\n";
254         $self->{pid} == $$ or
255                 die "BUG: atfork_prepare not called from $self->{pid}\n";
256         $self->{dbh} or die "temporary clone not open\n";
257         # must clobber prepared statements
258         %$self = (tmp_name => $f, pid => $$);
259 }
260
261 1;