Skip to content

lore: rebuild the lore BTree indices each run and compact small fragments - #123

Open
vladwing wants to merge 7 commits into
facebookexperimental:mainfrom
vladwing:lore-btree-indices
Open

vladwing wants to merge 7 commits into
facebookexperimental:mainfrom
vladwing:lore-btree-indices

Conversation

@vladwing

Copy link
Copy Markdown
Contributor

Depends on #113: this branch is based on #113's head, so its first five commits are #113's. This PR adds the last two, each described below; review them one at a time.

lore: rebuild the non-nullable lore BTree indices each run

The lore BTree indices are created at schema setup, usually on an empty table, and nothing updates them afterwards: optimize_single_table() skips lore, and optimize_lore_fts_indices() merges only the FTS indices. In a database built from scratch they cover no rows, so every lookup by message_id scans the whole table, and so does every in_reply_to lookup a thread walk makes. A long-lived database had about 106,000 of its 998,000 emails outside them, a number every refresh adds to.

compact_lore_tables() now rebuilds, after pruning old versions, the BTree indices on non-nullable columns: message_id, git_commit_sha and date, plus from and subject until the first FTS build replaces those with FTS indices. It builds them from the whole table each run rather than merging only the new rows into them, so no earlier failed run, and no rewrite of the table's fragments, can leave rows out.

The BTrees on the nullable in_reply_to and references columns stay as they are, because lance 4.0.0 writes a BTree over a nullable string column of that size into pages it cannot decode: reading the index back panics, and so does any lookup that lands on a bad page. in_reply_to lookups therefore still scan the rows its index does not cover. A comment at the filter names the defect, so a lance upgrade can lift it.

A failed rebuild is reported as a partial failure, like a failed prune. The rebuild runs as its own task, so a panic inside lance is reported the same way instead of ending semcode-index before the FTS update. prune_lore_table() is renamed maintain_lore_table() to match.

On a copy of a production database (about 998,000 rows, load average 100 to 170 on 316 CPUs), rebuilding the three BTrees took 1.6 to 1.8 s every run, about twice as long as merging new rows, and peaked at 265 MB on its own. A run that rebuilds them peaks at 1.7 GB, most of it from listing the table's indices, which opens the FTS indices that the FTS update opens anyway. The index copies a rebuild replaces stay under _indices/ until the next run prunes them, adding one BTree set to the non-empty entries optimize_lore_fts_indices() counts against its limit of 100.

lore: compact the lore table once it has many small fragments

Every lore run appends a few fragments to lore, and nothing merges them. A production database had reached 970 fragments, most of them a few rows, and queries that scan lore pay for each one: on a compacted copy, the thread lookup's references scan used 27 to 40 s of CPU instead of 51 to 56 s, and a date-bounded subject search 1.0 to 1.3 s instead of 2.0 to 2.8 s. optimize_single_table() skips the table, because a compaction that remaps every index on it peaked at 8.9 GB on a production-sized table, and panics on the references BTree, which lance 4.0.0 writes in a form it cannot read back.

compact_lore_tables() now prunes, then compacts lore once it has more than 256 fragments, then rebuilds the BTrees as before. Fragments of 150,000 rows or more are not rewritten. Index remapping is deferred: lance records the old-to-new fragment mapping in its fragment-reuse index and leaves the indices as they are, which keeps memory low. The compaction runs as its own task, like the rebuild, so a panic in it is reported as a partial failure.

Reads and the FTS update apply the fragment-reuse mapping, but lance 4.0.0's BTree update does not: after such a compaction it drops every row the index held in a rewritten fragment, while the index statistics still report full coverage. That is why the BTrees are rebuilt from the whole table each run rather than updated, and a new test checks that lookups find every row after a compaction of already-indexed fragments.

#122, which encodes lore vectors in 64-email chunks, should land before or with this commit: larger fragments let update_lore_vectors() hand its workers full 1,024-row batches, which raises the peak of a cold --vectors run.

Costs and caveats:

  • On a copy of the production database (998,000 rows, host load 130 to 200 on 316 CPUs), the first compaction took 970 fragments to 6 in 4.0 s, and the BTree rebuild after it 1.6 s, with a process peak of 3.3 GB.
  • A table built from scratch by the lore: speed up indexing on many-CPU hosts #112/lore: keep hourly lore refreshes from growing the database without bound #113 lore pipeline holds only small fragments (997,703 rows in 1,043 fragments), so its first compaction rewrites all of it: 30 to 38 s and a 7.6 GB peak with the rewrite tasks run one at a time (8.4 GB at two, 9.0 GB at four), measured in a process of its own. About 3 GB of that is glibc arena fragmentation: with MALLOC_ARENA_MAX=2 it peaks at 4.2 GB.
  • A cold build now ends with this compaction, in the process whose indexing peaked at 14.5 GB; that whole run's peak was not measured. Neither was an upstream-built table, which holds more, smaller fragments.
  • Replaced fragments stay on disk until the next run's prune, so a query still reading them is not cut off. That is about 0.25 GB for an hour on production, and the whole table's data once on a cold database (2.3 GB more here).
  • lance 4.0.0 never trims the fragment-reuse index, so each compaction adds a version to it, a few KB after the first. At 256 fragments a table compacts about every 25 hourly runs, so about 14 versions a year.

compact_lore_tables() skipped the lore table entirely and never
touched lore_vectors, so neither ever dropped an old version.  Every
hourly refresh rewrites the five lore FTS indices in full (the body
index alone is over 800 MB) and appends dozens of fragments, and all of
it stayed on disk: on one host lore grew from 3.9 GB to 20 GB in two
days, 17 GB of it superseded FTS indices.

It also wedged FTS updates.  optimize_lore_fts_indices() refuses to
run once lore.lance/_indices holds more than 100 entries, and with
nothing pruned the count only grew, by five per run.  After about 14
hourly runs the indices stopped covering new mail for good.

Prune old versions of lore from compact_lore_tables(), which only the
lore run calls.  Its Compact and index optimization phases, which the
existing comment blames for running out of memory, stay skipped;
prune reads only each manifest's index metadata.  optimize_database()
still skips lore: with delete_unverified set, a prune from another
process could delete files a running lore refresh has not committed.
Add lore_vectors to the tables compact_lore_tables() handles.

Put the prune in one helper, prune_old_versions(), used by
compact_lore_tables(), optimize_database() and
create_lore_fts_indices(), which carried its own copy of the same call
to clean up after rebuilding the FTS indices.

The prune runs before the run's FTS update, so one superseded set of
FTS indices remains on disk until the next run.  It deletes their
files but leaves their directories behind, so count only non-empty
entries in the _indices guard.
Each lore parser thread sends a batch once it holds batch_size
emails, and flushes whatever it has left when the commit list runs
out.  On an hourly refresh that adds a few hundred emails per archive,
every thread ends up with a short batch, and each of them became its
own append to lore and lore_indexed_commits: one host's refreshes
added 40 to 80 fragments to each table per run, and the lore_vectors
step then made one merge_insert per fragment.

A parser only sends a short batch when its input has run out, so when
an inserter receives one, keep receiving until it holds at least
batch_size emails or every parser has finished.  Full batches received
on their own are inserted as before, and a merged batch stays under
twice batch_size.  In a simulated hourly refresh of 50 new emails
with 32 parser threads, lore gains one fragment per run instead of 32.
update_lore_vectors() workers send one Vec per lore RecordBatch that
still has emails without a vector, and the inserter ran a
merge_insert into lore_vectors for each of them.  On an incremental
run most of those hold only a few vectors, while every merge_insert
joins against the whole table: one host's day of new mail (6,228
emails) took about 1,600 merge_inserts and 56 minutes, nearly all of
it in those joins, and each hourly run added 40 to 80 fragments to
lore_vectors.

Buffer vectors in the inserter and write once at least 8192 have
accumulated, flushing the remainder when the workers finish.  The
merge semantics are unchanged.  merge_insert inserts every unmatched
source row, so keep one entry per message_id in a buffer, as separate
merges used to.  An hourly-sized run now adds one fragment.

The progress bar still advances on every Vec the inserter receives,
not at each write, so on an incremental run it does not sit at zero
until the final write and then jump to the total.  Its count can now
run ahead of what has been written, so its message reads "Vectorized
N emails" rather than "Inserted N vectors".
A cold lore build appends one lore_indexed_commits fragment per inserted
batch: 1,013 for a production-sized set of archives, 3,660 for an
upstream build.  optimize_single_table() skips compaction above 500
fragments and nothing else brings the count down, so the table stays
over the limit for good.  Rebuilding with --clear, as the skip message
advises, recreates the table over the limit.

Exempt this table from the limit and keep the limit for every other
table.  Databases already over it compact on their next lore run that
adds mail.

The limit guards against a lance compaction CPU spin seen with thousands
of small fragments (61e5493).  lore_indexed_commits holds one 40-char
SHA per row and no index.  On lance 4.0.0 a table of that shape compacts
in under 5 s at both 1,013 and 3,660 fragments, with no spin, whether
compacted alone, as compact_lore_tables() does, or eight tables at a
time, as optimize_database() does.  The spin does not reproduce on lance
1.0.1 either, the version 61e5493 saw it on, so its cause is still
unknown.
semcode-index printed "Lore table compaction completed successfully"
and "FTS indices updated successfully" whatever had happened.  The
reasons for skipping or failing went to tracing::warn!, which the
default SEMCODE_DEBUG level filters out, so the lore FTS indices
stopped covering new mail for two days on one host without a line in
the hourly unit's log.

Print the FTS skip reasons and lore compaction problems to stderr.
optimize_lore_fts_indices() and compact_lore_tables() now return
whether they did their work, and the success lines are printed only
when they did.  optimize_single_table() also reports a table it did
not compact because it has more than 500 fragments as a partial
failure rather than success, since its fragment count only grows from
there.
The lore BTree indices are created at schema setup, usually on an empty
table, and nothing updates them afterwards: `optimize_single_table()`
skips lore, and `optimize_lore_fts_indices()` merges only the FTS
indices.  In a database built from scratch they cover no rows, so every
lookup by `message_id` scans the whole table, and so does every
`in_reply_to` lookup a thread walk makes.  A long-lived database had
about 106,000 of its 998,000 emails outside them, a number every refresh
adds to.

`compact_lore_tables()` now rebuilds, after pruning old versions, the
BTree indices on non-nullable columns: `message_id`, `git_commit_sha`
and `date`, plus `from` and `subject` until the first FTS build replaces
those with FTS indices.  It builds them from the whole table each run
rather than merging only the new rows into them, so no earlier failed
run, and no rewrite of the table's fragments, can leave rows out.

The BTrees on the nullable `in_reply_to` and `references` columns stay
as they are, because lance 4.0.0 writes a BTree over a nullable string
column of that size into pages it cannot decode: reading the index back
panics, and so does any lookup that lands on a bad page.  `in_reply_to`
lookups therefore still scan the rows its index does not cover.  A
comment at the filter names the defect, so a lance upgrade can lift it.

A failed rebuild is reported as a partial failure, like a failed prune.
The rebuild runs as its own task, so a panic inside lance is reported
the same way instead of ending semcode-index before the FTS update.
`prune_lore_table()` is renamed `maintain_lore_table()` to match.

On a copy of a production database (about 998,000 rows, load average 100
to 170 on 316 CPUs), rebuilding the three BTrees took 1.6 to 1.8 s every
run, about twice as long as merging new rows, and peaked at 265 MB on
its own.  A run that rebuilds them peaks at 1.7 GB, most of it from
listing the table's indices, which opens the FTS indices that the FTS
update opens anyway.  The index copies a rebuild replaces stay under
`_indices/` until the next run prunes them, adding one BTree set to the
non-empty entries `optimize_lore_fts_indices()` counts against its limit
of 100.
Every lore run appends a few fragments to `lore`, and nothing merges
them.  A production database had reached 970 fragments, most of them a
few rows, and queries that scan lore pay for each one: on a compacted
copy, the thread lookup's `references` scan used 27 to 40 s of CPU
instead of 51 to 56 s, and a date-bounded subject search 1.0 to 1.3 s
instead of 2.0 to 2.8 s.  `optimize_single_table()` skips the table,
because a compaction that remaps every index on it peaked at 8.9 GB on a
production-sized table, and panics on the `references` BTree, which
lance 4.0.0 writes in a form it cannot read back.

`compact_lore_tables()` now prunes, then compacts `lore` once it has
more than 256 fragments, then rebuilds the BTrees as before.  Fragments
of 150,000 rows or more are not rewritten.  Index remapping is deferred:
lance records the old-to-new fragment mapping in its fragment-reuse
index and leaves the indices as they are, which keeps memory low.  The
compaction runs as its own task, like the rebuild, so a panic in it is
reported as a partial failure.

Reads and the FTS update apply the fragment-reuse mapping, but lance
4.0.0's BTree update does not: after such a compaction it drops every
row the index held in a rewritten fragment, while the index statistics
still report full coverage.  That is why the BTrees are rebuilt from the
whole table each run rather than updated, and a new test checks that
lookups find every row after a compaction of already-indexed fragments.

facebookexperimental#122, which encodes lore vectors in 64-email chunks, should land before
or with this commit: larger fragments let `update_lore_vectors()` hand
its workers full 1,024-row batches, which raises the peak of a cold
`--vectors` run.

Costs and caveats:

- On a copy of the production database (998,000 rows, host load 130 to
  200 on 316 CPUs), the first compaction took 970 fragments to 6 in
  4.0 s, and the BTree rebuild after it 1.6 s, with a process peak of
  3.3 GB.
- A table built from scratch by the facebookexperimental#112/facebookexperimental#113 lore pipeline holds only
  small fragments (997,703 rows in 1,043 fragments), so its first
  compaction rewrites all of it: 30 to 38 s and a 7.6 GB peak with the
  rewrite tasks run one at a time (8.4 GB at two, 9.0 GB at four),
  measured in a process of its own.  About 3 GB of that is glibc arena
  fragmentation: with MALLOC_ARENA_MAX=2 it peaks at 4.2 GB.
- A cold build now ends with this compaction, in the process whose
  indexing peaked at 14.5 GB; that whole run's peak was not measured.
  Neither was an upstream-built table, which holds more, smaller
  fragments.
- Replaced fragments stay on disk until the next run's prune, so a query
  still reading them is not cut off.  That is about 0.25 GB for an hour
  on production, and the whole table's data once on a cold database
  (2.3 GB more here).
- lance 4.0.0 never trims the fragment-reuse index, so each compaction
  adds a version to it, a few KB after the first.  At 256 fragments a
  table compacts about every 25 hourly runs, so about 14 versions a
  year.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 11, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant