X-Git-Url: http://www.git.stargrave.org/?a=blobdiff_plain;f=lib%2FPublicInbox%2FOverIdx.pm;h=6cc86d5d038d3669b0991d270aa74234f3937560;hb=HEAD;hp=9ee6d613a8c4ab636bc75015dcb225aa0a362b54;hpb=8e81d6f0d44198717ae540421a09824d75c9bb6d;p=public-inbox.git diff --git a/lib/PublicInbox/OverIdx.pm b/lib/PublicInbox/OverIdx.pm index 9ee6d613..6cc86d5d 100644 --- a/lib/PublicInbox/OverIdx.pm +++ b/lib/PublicInbox/OverIdx.pm @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2020 all contributors +# Copyright (C) all contributors # License: AGPL-3.0+ # for XOVER, OVER in NNTP, and feeds/homepage/threads in PSGI @@ -9,25 +9,36 @@ # are denoted by a negative NNTP article number. package PublicInbox::OverIdx; use strict; -use warnings; -use base qw(PublicInbox::Over); +use v5.10.1; +use parent qw(PublicInbox::Over); use IO::Handle; use DBI qw(:sql_types); # SQL_BLOB use PublicInbox::MID qw/id_compress mids_for_index references/; -use PublicInbox::SearchMsg qw(subject_normalized); -use PublicInbox::MsgTime qw(msg_timestamp msg_datestamp); +use PublicInbox::Smsg qw(subject_normalized); use Compress::Zlib qw(compress); -use PublicInbox::Search; +use Carp qw(croak); sub dbh_new { my ($self) = @_; - my $dbh = $self->SUPER::dbh_new(1); - $dbh->do('PRAGMA journal_mode = TRUNCATE'); + my $dbh = $self->SUPER::dbh_new($self->{-no_fsync} ? 2 : 1); + + # 80000 pages (80MiB on SQLite <3.12.0, 320MiB on 3.12.0+) + # was found to be good in 2018 during the large LKML import + # at the time. This ought to be configurable based on HW + # and inbox size; I suspect it's overkill for many inboxes. $dbh->do('PRAGMA cache_size = 80000'); + create_tables($dbh); $dbh; } +sub new { + my ($class, $f) = @_; + my $self = $class->SUPER::new($f); + $self->{min_tid} = 0; + $self; +} + sub get_counter ($$) { my ($dbh, $key) = @_; my $sth = $dbh->prepare_cached(<<'', undef, 1); @@ -68,6 +79,11 @@ SELECT $id_col FROM $tbl WHERE $val_col = ? LIMIT 1 } } +sub ibx_id { + my ($self, $eidx_key) = @_; + id_for($self, 'inboxes', 'ibx_id', eidx_key => $eidx_key); +} + sub sid { my ($self, $path) = @_; return unless defined $path && $path ne ''; @@ -98,7 +114,7 @@ DELETE FROM $_ WHERE num = ? # this includes ghosts sub each_by_mid { - my ($self, $mid, $cols, $cb) = @_; + my ($self, $mid, $cols, $cb, @arg) = @_; my $dbh = $self->{dbh}; =over @@ -142,45 +158,58 @@ SELECT $cols FROM over WHERE over.num = ? LIMIT 1 foreach (@$nums) { $sth->execute($_->[0]); - my $smsg = $sth->fetchrow_hashref; - $cb->(PublicInbox::Over::load_from_row($smsg)) or - return; + # $cb may delete rows and invalidate nums + my $smsg = $sth->fetchrow_hashref // next; + $smsg = PublicInbox::Over::load_from_row($smsg); + $cb->($self, $smsg, @arg) or return; } return if $nr != $lim; } } -# this will create a ghost as necessary -sub resolve_mid_to_tid { - my ($self, $mid) = @_; - my $tid; - each_by_mid($self, $mid, ['tid'], sub { - my ($smsg) = @_; - my $cur_tid = $smsg->{tid}; - if (defined $tid) { - merge_threads($self, $tid, $cur_tid); - } else { - $tid = $cur_tid; +sub _resolve_mid_to_tid { + my ($self, $smsg, $tid) = @_; + my $cur_tid = $smsg->{tid}; + if (defined $$tid) { + merge_threads($self, $$tid, $cur_tid); + } elsif ($cur_tid > $self->{min_tid}) { + $$tid = $cur_tid; + } else { # rethreading, queue up dead ghosts + $$tid = next_tid($self); + my $n = $smsg->{num}; + if ($n > 0) { + $self->{dbh}->prepare_cached(<<'')->execute($$tid, $n); +UPDATE over SET tid = ? WHERE num = ? + + } elsif ($n < 0) { + push(@{$self->{-ghosts_to_delete}}, $n); } - 1; - }); - defined $tid ? $tid : create_ghost($self, $mid); + } + 1; } -sub create_ghost { +# this will create a ghost as necessary +sub resolve_mid_to_tid { my ($self, $mid) = @_; - my $id = $self->mid2id($mid); - my $num = $self->next_ghost_num; - $num < 0 or die "ghost num is non-negative: $num\n"; - my $tid = $self->next_tid; - my $dbh = $self->{dbh}; - $dbh->prepare_cached(<<'')->execute($num, $tid); + my $tid; + each_by_mid($self, $mid, ['tid'], \&_resolve_mid_to_tid, \$tid); + if (my $del = delete $self->{-ghosts_to_delete}) { + delete_by_num($self, $_) for @$del; + } + $tid // do { # create a new ghost + my $id = mid2id($self, $mid); + my $num = next_ghost_num($self); + $num < 0 or die "ghost num is non-negative: $num\n"; + $tid = next_tid($self); + my $dbh = $self->{dbh}; + $dbh->prepare_cached(<<'')->execute($num, $tid); INSERT INTO over (num, tid) VALUES (?,?) - $dbh->prepare_cached(<<'')->execute($id, $num); + $dbh->prepare_cached(<<'')->execute($id, $num); INSERT INTO id2num (id, num) VALUES (?,?) - $tid; + $tid; + }; } sub merge_threads { @@ -210,148 +239,140 @@ sub link_refs { merge_threads($self, $tid, $ptid); } } else { - $tid = defined $old_tid ? $old_tid : $self->next_tid; + $tid = $old_tid // next_tid($self); } $tid; } -sub parse_references ($$$) { - my ($smsg, $mid0, $mids) = @_; - my $mime = $smsg->{mime}; - my $hdr = $mime->header_obj; - my $refs = references($hdr); - push(@$refs, @$mids) if scalar(@$mids) > 1; - return $refs if scalar(@$refs) == 0; - - # prevent circular references here: - my %seen = ( $mid0 => 1 ); - my @keep; - foreach my $ref (@$refs) { - if (length($ref) > PublicInbox::MID::MAX_MID_SIZE) { - warn "References: <$ref> too long, ignoring\n"; - next; - } - push(@keep, $ref) unless $seen{$ref}++; - } - $smsg->{references} = '<'.join('> <', @keep).'>' if @keep; - \@keep; -} - -# normalize subjects so they are suitable as pathnames for URLs -# XXX: consider for removal +# normalize subjects somewhat, they used to be ASCII-only but now +# we use \w for UTF-8 support. We may still drop it entirely and +# rely on Xapian for subject matches... sub subject_path ($) { my ($subj) = @_; $subj = subject_normalized($subj); - $subj =~ s![^a-zA-Z0-9_\.~/\-]+!_!g; + $subj =~ s![^\w\.~/\-]+!_!g; lc($subj); } +sub ddd_for ($) { + my ($smsg) = @_; + my $dd = $smsg->to_doc_data; + utf8::encode($dd); + compress($dd); +} + sub add_overview { - my ($self, $mime, $bytes, $num, $oid, $mid0, $times) = @_; - my $lines = $mime->body_raw =~ tr!\n!\n!; - my $smsg = bless { - mime => $mime, - mid => $mid0, - bytes => $bytes, - lines => $lines, - blob => $oid, - }, 'PublicInbox::SearchMsg'; - my $hdr = $mime->header_obj; - my $mids = mids_for_index($hdr); - my $refs = parse_references($smsg, $mid0, $mids); - my $subj = $smsg->subject; + my ($self, $eml, $smsg) = @_; + $smsg->{lines} = $eml->body_raw =~ tr!\n!\n!; + my $mids = mids_for_index($eml); + my $refs = $smsg->parse_references($eml, $mids); + $mids->[0] //= do { + $smsg->{mid} //= ''; + $eml->{-lei_fake_mid}; + }; + my $subj = $smsg->{subject}; my $xpath; if ($subj ne '') { $xpath = subject_path($subj); $xpath = id_compress($xpath); } - my $dd = $smsg->to_doc_data($oid, $mid0); - utf8::encode($dd); - $dd = compress($dd); - my $ds = msg_timestamp($hdr, $times->{autime}); - my $ts = msg_datestamp($hdr, $times->{cotime}); - my $values = [ $ts, $ds, $num, $mids, $refs, $xpath, $dd ]; - add_over($self, $values); + add_over($self, $smsg, $mids, $refs, $xpath, ddd_for($smsg)); +} + +sub _add_over { + my ($self, $smsg, $mid, $refs, $old_tid, $v) = @_; + my $cur_tid = $smsg->{tid}; + my $n = $smsg->{num}; + die "num must not be zero for $mid" if !$n; + my $cur_valid = $cur_tid > $self->{min_tid}; + + if ($n > 0) { # regular mail + if ($cur_valid) { + $$old_tid //= $cur_tid; + merge_threads($self, $$old_tid, $cur_tid); + } else { + $$old_tid //= next_tid($self); + } + } elsif ($n < 0) { # ghost + $$old_tid //= $cur_valid ? $cur_tid : next_tid($self); + $$old_tid = link_refs($self, $refs, $$old_tid); + delete_by_num($self, $n); + $$v++; + } + 1; } sub add_over { - my ($self, $values) = @_; - my ($ts, $ds, $num, $mids, $refs, $xpath, $ddd) = @$values; + my ($self, $smsg, $mids, $refs, $xpath, $ddd) = @_; my $old_tid; my $vivified = 0; + my $num = $smsg->{num}; - $self->begin_lazy; - $self->delete_by_num($num, \$old_tid); + begin_lazy($self); + delete_by_num($self, $num, \$old_tid); + $old_tid = undef if ($old_tid // 0) <= $self->{min_tid}; foreach my $mid (@$mids) { my $v = 0; - each_by_mid($self, $mid, ['tid'], sub { - my ($cur) = @_; - my $cur_tid = $cur->{tid}; - my $n = $cur->{num}; - die "num must not be zero for $mid" if !$n; - $old_tid = $cur_tid unless defined $old_tid; - if ($n > 0) { # regular mail - merge_threads($self, $old_tid, $cur_tid); - } elsif ($n < 0) { # ghost - link_refs($self, $refs, $old_tid); - $self->delete_by_num($n); - $v++; - } - 1; - }); + each_by_mid($self, $mid, ['tid'], \&_add_over, + $mid, $refs, \$old_tid, \$v); $v > 1 and warn "BUG: vivified multiple ($v) ghosts for $mid\n"; $vivified += $v; } - my $tid = $vivified ? $old_tid : link_refs($self, $refs, $old_tid); - my $sid = $self->sid($xpath); + $smsg->{tid} = $vivified ? $old_tid : link_refs($self, $refs, $old_tid); + $smsg->{sid} = sid($self, $xpath); my $dbh = $self->{dbh}; my $sth = $dbh->prepare_cached(<<''); INSERT INTO over (num, tid, sid, ts, ds, ddd) VALUES (?,?,?,?,?,?) - my $n = 0; - my @v = ($num, $tid, $sid, $ts, $ds); - foreach (@v) { $sth->bind_param(++$n, $_) } - $sth->bind_param(++$n, $ddd, SQL_BLOB); + my $nc = 1; + $sth->bind_param($nc, $num); + $sth->bind_param(++$nc, $smsg->{$_}) for (qw(tid sid ts ds)); + $sth->bind_param(++$nc, $ddd, SQL_BLOB); $sth->execute; $sth = $dbh->prepare_cached(<<''); INSERT INTO id2num (id, num) VALUES (?,?) foreach my $mid (@$mids) { - my $id = $self->mid2id($mid); + my $id = mid2id($self, $mid); $sth->execute($id, $num); } } -# returns number of removed messages +sub _remove_oid { + my ($self, $smsg, $oid, $removed) = @_; + if (!defined($oid) || $smsg->{blob} eq $oid) { + delete_by_num($self, $smsg->{num}); + push @$removed, $smsg->{num}; + } + 1; +} + +# returns number of removed messages in scalar context, +# array of removed article numbers in array context. # $oid may be undef to match only on $mid sub remove_oid { my ($self, $oid, $mid) = @_; - my $nr = 0; - $self->begin_lazy; - each_by_mid($self, $mid, ['ddd'], sub { - my ($smsg) = @_; - if (!defined($oid) || $smsg->{blob} eq $oid) { - $self->delete_by_num($smsg->{num}); - $nr++; - } - 1; - }); - $nr; + my $removed = []; + begin_lazy($self); + each_by_mid($self, $mid, ['ddd'], \&_remove_oid, $oid, $removed); + @$removed; +} + +sub _num_mid0_for_oid { + my ($self, $smsg, $oid, $res) = @_; + my $blob = $smsg->{blob}; + return 1 if (!defined($blob) || $blob ne $oid); # continue; + @$res = ($smsg->{num}, $smsg->{mid}); + 0; # done } sub num_mid0_for_oid { my ($self, $oid, $mid) = @_; - my ($num, $mid0); - $self->begin_lazy; - each_by_mid($self, $mid, ['ddd'], sub { - my ($smsg) = @_; - my $blob = $smsg->{blob}; - return 1 if (!defined($blob) || $blob ne $oid); # continue; - ($num, $mid0) = ($smsg->{num}, $smsg->{mid}); - 0; # done - }); - ($num, $mid0); + my $res = []; + begin_lazy($self); + each_by_mid($self, $mid, ['ddd'], \&_num_mid0_for_oid, $oid, $res); + @$res, # ($num, $mid0); } sub create_tables { @@ -359,13 +380,12 @@ sub create_tables { $dbh->do(<<''); CREATE TABLE IF NOT EXISTS over ( - num INTEGER NOT NULL, - tid INTEGER NOT NULL, - sid INTEGER, - ts INTEGER, - ds INTEGER, - ddd VARBINARY, /* doc-data-deflated */ - UNIQUE (num) + num INTEGER PRIMARY KEY NOT NULL, /* NNTP article number == IMAP UID */ + tid INTEGER NOT NULL, /* THREADID (IMAP REFERENCES threading, JMAP) */ + sid INTEGER, /* Subject ID (IMAP ORDEREDSUBJECT "threading") */ + ts INTEGER, /* IMAP INTERNALDATE (Received: header, git commit time) */ + ds INTEGER, /* RFC-2822 sent Date: header, git author time */ + ddd VARBINARY /* doc-data-deflated (->to_doc_data, ->load_from_data) */ ) $dbh->do('CREATE INDEX IF NOT EXISTS idx_tid ON over (tid)'); @@ -386,13 +406,13 @@ CREATE TABLE IF NOT EXISTS counter ( $dbh->do(<<''); CREATE TABLE IF NOT EXISTS subject ( sid INTEGER PRIMARY KEY AUTOINCREMENT, - path VARCHAR(40) NOT NULL, + path VARCHAR(40) NOT NULL, /* SHA-1 of normalized subject */ UNIQUE (path) ) $dbh->do(<<''); CREATE TABLE IF NOT EXISTS id2num ( - id INTEGER NOT NULL, + id INTEGER NOT NULL, /* <=> msgid.id */ num INTEGER NOT NULL, UNIQUE (id, num) ) @@ -403,7 +423,7 @@ CREATE TABLE IF NOT EXISTS id2num ( $dbh->do(<<''); CREATE TABLE IF NOT EXISTS msgid ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id INTEGER PRIMARY KEY AUTOINCREMENT, /* <=> id2num.id */ mid VARCHAR(244) NOT NULL, UNIQUE (mid) ) @@ -414,12 +434,13 @@ sub commit_lazy { my ($self) = @_; delete $self->{txn} or return; $self->{dbh}->commit; + eval { $self->{dbh}->do('PRAGMA optimize') }; } sub begin_lazy { my ($self) = @_; return if $self->{txn}; - my $dbh = $self->connect or return; + my $dbh = $self->dbh or return; $dbh->begin_work; # $dbh->{Profile} = 2; $self->{txn} = 1; @@ -431,22 +452,234 @@ sub rollback_lazy { $self->{dbh}->rollback; } -sub disconnect { +sub dbh_close { my ($self) = @_; die "in transaction" if $self->{txn}; - $self->{dbh} = undef; + $self->SUPER::dbh_close; } sub create { my ($self) = @_; - unless (-r $self->{filename}) { + my $fn = $self->{filename} // do { + croak('BUG: no {filename}') unless $self->{dbh}; + return; + }; + unless (-r $fn) { require File::Path; - require File::Basename; - File::Path::mkpath(File::Basename::dirname($self->{filename})); + my ($dir) = ($fn =~ m!(.*?/)[^/]+\z!); + File::Path::mkpath($dir); } # create the DB: - PublicInbox::Over::connect($self); - $self->disconnect; + PublicInbox::Over::dbh($self); + $self->dbh_close; +} + +sub rethread_prepare { + my ($self, $opt) = @_; + return unless $opt->{rethread}; + begin_lazy($self); + my $min = $self->{min_tid} = get_counter($self->{dbh}, 'thread') // 0; + my $pr = $opt->{-progress}; + $pr->("rethread min THREADID ".($min + 1)."\n") if $pr && $min; +} + +sub rethread_done { + my ($self, $opt) = @_; + return unless $opt->{rethread} && $self->{txn}; + defined(my $min = $self->{min_tid}) or croak('BUG: no min_tid'); + my $dbh = $self->{dbh} or croak('BUG: no dbh'); + my $rows = $dbh->selectall_arrayref(<<'', { Slice => {} }, $min); +SELECT num,tid FROM over WHERE num < 0 AND tid < ? + + my $show_id = $dbh->prepare('SELECT id FROM id2num WHERE num = ?'); + my $show_mid = $dbh->prepare('SELECT mid FROM msgid WHERE id = ?'); + my $pr = $opt->{-progress}; + my $total = 0; + for my $r (@$rows) { + my $exp = 0; + $show_id->execute($r->{num}); + while (defined(my $id = $show_id->fetchrow_array)) { + ++$exp; + $show_mid->execute($id); + my $mid = $show_mid->fetchrow_array; + if (!defined($mid)) { + warn <{num} ID=$id THREADID=$r->{tid} has no Message-ID +EOF + next; + } + $pr->(<{num} <$mid> THREADID=$r->{tid} culled +EOM + } + delete_by_num($self, $r->{num}); + } + $pr->("# rethread culled $total ghosts\n") if $pr && $total; +} + +# used for cross-inbox search +sub eidx_prep ($) { + my ($self) = @_; + $self->{-eidx_prep} //= do { + my $dbh = $self->dbh; + $dbh->do(<<''); +INSERT OR IGNORE INTO counter (key) VALUES ('eidx_docid') + + $dbh->do(<<''); +CREATE TABLE IF NOT EXISTS inboxes ( + ibx_id INTEGER PRIMARY KEY AUTOINCREMENT, + eidx_key VARCHAR(255) NOT NULL, /* {newsgroup} // {inboxdir} */ + UNIQUE (eidx_key) +) + + $dbh->do(<<''); +CREATE TABLE IF NOT EXISTS xref3 ( + docid INTEGER NOT NULL, /* <=> over.num */ + ibx_id INTEGER NOT NULL, /* <=> inboxes.ibx_id */ + xnum INTEGER NOT NULL, /* NNTP article number in ibx */ + oidbin VARBINARY NOT NULL, /* 20-byte SHA-1 or 32-byte SHA-256 */ + UNIQUE (docid, ibx_id, xnum, oidbin) +) + + $dbh->do('CREATE INDEX IF NOT EXISTS idx_docid ON xref3 (docid)'); + + # performance critical, this is not UNIQUE since we may need to + # tolerate some old bugs from indexing mirrors. n.b. we used + # to index oidbin here, but leaving it out speeds up reindexing + # and "XHDR Xref <$MSGID>" isn't any slower w/o oidbin + $dbh->do('CREATE INDEX IF NOT EXISTS idx_reindex ON '. + 'xref3 (xnum,ibx_id)'); + + $dbh->do('CREATE INDEX IF NOT EXISTS idx_oidbin ON xref3 (oidbin)'); + + $dbh->do(<<''); +CREATE TABLE IF NOT EXISTS eidx_meta ( + key VARCHAR(255) PRIMARY KEY, + val VARCHAR(255) NOT NULL +) + + # A queue of current docids which need reindexing. + # eidxq persists across aborted -extindex invocations + # Currently used for "-extindex --reindex" for Xapian + # data, but may be used in more places down the line. + $dbh->do(<<''); +CREATE TABLE IF NOT EXISTS eidxq (docid INTEGER PRIMARY KEY NOT NULL) + + 1; + }; +} + +sub eidx_meta { # requires transaction + my ($self, $key, $val) = @_; + + my $sql = 'SELECT val FROM eidx_meta WHERE key = ? LIMIT 1'; + my $dbh = $self->{dbh}; + defined($val) or return $dbh->selectrow_array($sql, undef, $key); + + my $prev = $dbh->selectrow_array($sql, undef, $key); + if (defined $prev) { + $sql = 'UPDATE eidx_meta SET val = ? WHERE key = ?'; + $dbh->do($sql, undef, $val, $key); + } else { + $sql = 'INSERT INTO eidx_meta (key,val) VALUES (?,?)'; + $dbh->do($sql, undef, $key, $val); + } + $prev; +} + +sub eidx_max { + my ($self) = @_; + get_counter($self->{dbh}, 'eidx_docid'); +} + +sub add_xref3 { + my ($self, $docid, $xnum, $oidhex, $eidx_key) = @_; + begin_lazy($self); + my $ibx_id = ibx_id($self, $eidx_key); + my $oidbin = pack('H*', $oidhex); + my $sth = $self->{dbh}->prepare_cached(<<''); +INSERT OR IGNORE INTO xref3 (docid, ibx_id, xnum, oidbin) VALUES (?, ?, ?, ?) + + $sth->bind_param(1, $docid); + $sth->bind_param(2, $ibx_id); + $sth->bind_param(3, $xnum); + $sth->bind_param(4, $oidbin, SQL_BLOB); + $sth->execute; +} + +# for when an xref3 goes missing, this does NOT update {ts} +sub update_blob { + my ($self, $smsg, $oidhex) = @_; + my $sth = $self->{dbh}->prepare(<<''); +UPDATE over SET ddd = ? WHERE num = ? + + $smsg->{blob} = $oidhex; + $sth->bind_param(1, ddd_for($smsg), SQL_BLOB); + $sth->bind_param(2, $smsg->{num}); + $sth->execute; +} + +sub merge_xref3 { # used for "-extindex --dedupe" + my ($self, $keep_docid, $drop_docid, $oidbin) = @_; + my $sth = $self->{dbh}->prepare_cached(<<''); +UPDATE OR IGNORE xref3 SET docid = ? WHERE docid = ? AND oidbin = ? + + $sth->bind_param(1, $keep_docid); + $sth->bind_param(2, $drop_docid); + $sth->bind_param(3, $oidbin, SQL_BLOB); + $sth->execute; + + # drop anything that conflicted + $sth = $self->{dbh}->prepare_cached(<<''); +DELETE FROM xref3 WHERE docid = ? AND oidbin = ? + + $sth->bind_param(1, $drop_docid); + $sth->bind_param(2, $oidbin, SQL_BLOB); + $sth->execute; +} + +sub eidxq_add { + my ($self, $docid) = @_; + $self->dbh->prepare_cached(<<'')->execute($docid); +INSERT OR IGNORE INTO eidxq (docid) VALUES (?) + +} + +sub eidxq_del { + my ($self, $docid) = @_; + $self->dbh->prepare_cached(<<'')->execute($docid); +DELETE FROM eidxq WHERE docid = ? + +} + +# returns true if we're vivifying a message for lei/store that was +# previously external-metadata only +sub vivify_xvmd { + my ($self, $smsg) = @_; + my @docids = $self->blob_exists($smsg->{blob}); + my @vivify_xvmd; + for my $id (@docids) { + if (my $cur = $self->get_art($id)) { + # already indexed if bytes > 0 + return if $cur->{bytes} > 0; + push @vivify_xvmd, $id; + } else { + warn "W: $smsg->{blob} #$id gone (bug?)\n"; + } + } + $smsg->{-vivify_xvmd} = \@vivify_xvmd; +} + +sub fork_ok { + return 1 if $DBD::SQLite::sqlite_version >= 3008003; + my ($opt) = @_; + my @j = split(/,/, $opt->{jobs} // ''); + state $warned; + grep { $_ > 1 } @j and $warned //= warn('DBD::SQLite version is ', + $DBD::SQLite::sqlite_version, + ", need >= 3008003 (3.8.3) for --jobs > 1\n"); + $opt->{jobs} = '1,1'; + undef; } 1;