~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve import reads hatk's grain.db into a new index

Merged
Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:index/import

What changes

grain-serve import <hatk's grain.db> [--into <index>] [--strict]. It reads hatk's SQLite database into a new index: GRAIN_INDEX, unless --into names another one. It opens the old database read-only and reads it in one snapshot. The target must be new, or an index that holds nothing. If it is anything else, including hatk's own file, the import refuses before touching it. The index is written in one transaction, so it ends up whole or empty. crates/grain-serve/src/index/import.rs documents the whole mapping.

Records. Each of hatk's [<nsid>] tables becomes the index's typed table. uri, cid, did and indexed_at are kept. Each row is rebuilt as its record from hatk's columns (hatk names each column by snake-casing the field, so iSO is i_s_o, and keeps JSON as text). The record is then checked against its lexicon, exactly as a live put is checked, and written through records::Any. Self-labels are made again from the record. The rebuilt record also fills the generated sort_at and h3. Germ's bytes are turned back into {"$bytes": …} from each form hatk stored: a BLOB, a Uint8Array's String() (3,55,54), its JSON ({"0":3,…}), or Jetstream's {"$bytes"}. Some records are refused and counted per table without stopping the import: no CID, a URI that is not its author's, or a record that fails its lexicon. --strict makes any refusal fatal. Rows of a permissioned space (space not null) are counted and left out. hatk's child tables (<nsid>__<field>) are not read, because each array also sits in its parent's JSON column.

What the network cannot rebuild, row for row. _labels→label and _reports→report keep their ids, so label sequence numbers and report ids mean what they did (a new label numbers on from hatk's last). An account label keeps its bare-DID uri. The other tables copy unchanged: _mutes→mute, _preferences→preference, _push_tokens→push_token, _story_views→story_view, _space_invites→space_invite, _space_support→space_support. _cursor's jetstream:<url> rows go to stream_cursor unchanged, so Jetstream resumes from jetstream:wss://jetstream.us-west.bsky.network.

_repos.status is split in two.

  • Only hatk's admin routes ever write takendown (/admin/takedown and a report's takedown action). Host takedowns go through handleAccountEvent, which writes deactivated. So each takendown row becomes a takedown row, with at set to the import time because hatk kept no timestamp.
  • deactivated (hatk's single word for a host saying deactivated, suspended or takendown) and deleted become repo.host_status.
  • The rest is backfill's queue:
    • pending and active stay as they are.
    • failed stays failed where hatk had given up (retry_count ≥ 5, its default), and becomes pending at its retry_after where hatk would have retried.
    • A takendown, deactivated or deleted repo becomes active if it has a rev (it was read), and pending if not.
    • A status hatk never writes becomes pending, is recorded in last_error, and is named in the summary.

The check, at runtime. The import prints source and imported counts for every record table and every kept table (the ones above plus repo and stream_cursor). It also prints each refused record with the reason, and every hatk table it did not import with its row count. The imported count is taken from the index's own table. If any kept table differs, for example a row the index would not take, nothing is written and the command exits 1. Exit codes: 0 when written, 1 when not, 2 on bad usage.

Why

The owner-approved plan for leaving hatk: grain-serve import copies column for column and keeps indexed_at, because feeds order by it. The irreplaceable tables carry over whole, and admin takedowns are kept apart from what hosts report, so a rebuild can never reinstate a taken-down account.

Records are validated by rebuilding them rather than by copying cell to cell. That way an imported row is exactly what a live put of the same record would have written, and anything the new index would refuse gets counted instead of silently landing.

How it was checked

Unit tests in crates/grain-serve/src/index/import/tests.rs build a fixture database with hatk's DDL. The DDL comes from initDatabase, grain's server/setup scripts, and hatk's schema generator, and was checked against a local dev grain.db. The fixture includes an older collection table with no space column, and a collection with no table at all.

  • Kept tables: every row of each one is asserted, in content and not only in count. The fixture covers a repo of each hatk status (including a takedown with and without a rev, and an unknown status), a record label, a label on a bare DID given and then negated, a NULL neg, a push token for each platform, an open report and a resolved report, and a relay cursor left behind.
  • Through Index: the result is also read through the index's own API (cursor, taken_down, host_status, labels, label numbering).
  • Records: counts per table, indexed_at kept, JSON columns intact, the generated h3 and sort_at, self-labels, and each bytes form. Six records are refused for different reasons, and each is counted, not fatal.
  • Failure paths: --strict writes nothing, and the same index can then be imported into. A NULL preference time (hatk allows it, the index does not) causes a DIFFERS, nothing is written, and the exit is 1. The import refuses a non-empty index, hatk's own file, a database that is not hatk's, and paths it cannot open.
  • CLI: argument parsing, and main's exit codes.
  • Binary: tests/main.rs runs grain-serve import with GRAIN_INDEX.

Nothing was built or run on this machine; CI runs fmt, clippy, the tests and the coverage gate.

Shortcomings

  • Speed (an estimate, not measured). Each record is rebuilt and validated (tens of µs), then inserted into tables whose indexes already exist. A few million rows, which is what a ~1 GB database likely holds (mostly app.bsky.feed.post), should take on the order of one to a few minutes. The time is linear in rows. The write uses a rollback journal on a fresh file, which journals almost nothing, plus a 256 MiB page cache and one commit. The import does not run in parallel. Dropping and rebuilding the indexes would be the next step if prod turns out slower.
  • Not imported, on purpose (each is listed in the summary with its rows):
    • _oauth_*: web sessions sign in again. Per the plan, "web sessions only, or nothing", and this pull does nothing.
    • _fts_*, _classifications, _space_watch, _space_repos.
    • Cursors other than Jetstream's (relay:*).
    • hatk's child tables.
    • Collections the index does not keep (app.bsky.actor.profile, app.bsky.feed.postgate, threadgate).
    • Space rows.
  • What the new schema cannot hold:
    • A takedown's time: hatk kept none, so the import time stands in.
    • Which of deactivated, suspended or host-takedown a deactivated repo was: hatk had already collapsed them.
    • A record's space.
    • The deprecated app.bsky.feed.post entities, which hatk kept only in a child table. They are not in the rebuilt record, which is fine because the field is optional.
    • Imported records get no record_rev, which backfill treats as older than any revision it reads.
  • Assumption: hatk's maxRetries is taken to be 5. If prod set BACKFILL_MAX_RETRIES, the line between failed and pending moves.

Review

The design and the status mapping: start at the module doc and REPO in import.rs.

🤖 Generated with Claude Code

chadtmiller.com added 1 commit
Approved

This adds grain-serve import. It reads hatk's database read-only into a new or empty index in one transaction. It rebuilds each record and validates it through the lexicon and records::Any. It copies the tables the network can't rebuild, and splits _repos.status into takedown, repo.host_status and backfill state. It compares counts per table and writes nothing if a kept table differs.

I read import.rs in full and found nothing that must change:

  • The index is refused before any write if it holds anything, including hatk's own file.
  • Admin takedowns are kept apart from host status as the plan requires, and a NULL retry_count falls to pending.
  • A failed insert into a kept table is reported as a count mismatch, so it never lands as a silent partial copy.

I did not read the tests, and I did not read all of the diff outside import.rs, which is about 87k characters. The checks (check, rust, test, harness) were still queued when I looked. The coverage gate in harness/cov.json and clippy have not reported on this head, so read them before merging.

One thing to confirm, not block on: value returns a SQLite integer as a JSON number for every kind. If any collection the index keeps has a boolean record field that hatk stored as 0/1, those rows would fail the lexicon and be counted as refused. A row-count check against a real grain.db would show it.

On booleans: none of the 17 collections the index keeps has a boolean record field (their properties are strings, integers, blobs, refs, unions, arrays and bytes), so no 0/1 column reaches value. fe819be only puts two of the summary's writes on one line each, for the coverage report, which had their ? error paths as uncovered lines.

Approved

My earlier review asked for nothing to change, and fe819be only puts two of the summary's writes on one line each so the coverage report reaches them. That doesn't change behavior. The author also answered my boolean question: none of the 17 kept collections has a boolean record field, so a 0/1 column can't fail the lexicon.

CI hasn't finished on this head. check passed. rust, harness and test were still running or queued when I looked. The coverage gate in harness/cov.json and clippy haven't reported yet, so read them before merging.

Merged as fe819be
Part of a stack of 2
  1. grain-serve import reads hatk's grain.db into a new index Landed as fe819be
  2. main