Skip to content

feat(cli): migrate a Postgres relay into LMDB, with resume, verify and a reported throughput - #1125

Open
chappie-daemon wants to merge 48 commits into
rewrite/mainfrom
factory/029-pg-to-lmdb
Open

chappie-daemon wants to merge 48 commits into
rewrite/mainfrom
factory/029-pg-to-lmdb

Conversation

@chappie-daemon

@chappie-daemon chappie-daemon commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

nostream-cli migrate pg-to-lmdb: the Postgres → LMDB copy that a cutover needs, with a
checksum manifest, per-table resume, a verification pass and its own throughput measurement.

This is the remainder of #864 — the store slice landed in #1073, LMDB NIP-50 is #1113, the
benchmark rig is board row 1d, and this is the migration itself: #1115.

The shape

  • A migration-only seam in nostream-storage (src/migration.rs, lmdb/migration.rs,
    postgres/migration.rs). It is not the nine repository traits: no trait enumerates a table,
    find_by_filters excludes soft-deleted and expired events, and every trait write stamps,
    clears or drops a column (first_seen: now, amount_paid/confirmed_at cleared,
    UserPatch carries neither balance nor created_at). A trait-shaped copy would be lossy
    and partial; the seam reads each table one keyset page at a time with no filter and writes
    each row with the source's own values through the per-table writer the store already has.
  • Nine tables copied exactly. Two stated limits, printed in the manifest: event_tags
    (the LMDB tags index is written from the event row) and user_subscriptions (no LMDB
    database, no trait — ADR 0011).
  • migration-manifest.json — format: 1, per table rows/checksum/after/complete —
    written by temp file, fsync, rename, directory fsync, after every batch and before the
    first one.
  • Checksum — the 256-bit multiset sum of SHA-256(len ‖ key ‖ canonical), modulo 2^256:
    order-free, constant memory, composable across a resume, and stated as a corruption check
    rather than a commitment (ADR 0012 D4).
  • Refusals, each before anything is written: a target that holds rows and has no manifest;
    a manifest that records progress over a missing store (No store at …); a manifest whose
    format, table set or not_migrated is not this tool's; a store whose tables lost the rows
    the manifest counts (data.mdb present, the tables gone) is refused by reading one row per
    counted table, before anything is read from the source or written to the store. The progress check is a file test on
    <target>/lmdb/data.mdb, not an open: heed keeps its Env clone in a process-global
    registry, so dropping a read-only probe's handle does not free the path and the later writable
    open answers already opened with different options. --verify opens the store read-only and
    creates no store and no manifest; LMDB's read-only open still recreates its lock.mdb.
  • Throughput is reported, not asserted: Migrated {n} rows in {ms} ms and
    Events throughput: {r} events/s. rate is events * 1000 / max(ms, 1) and AC7 checks that
    identity over a target that sleeps a known 20 ms per batch, so a constant ms cannot pass.
    No floor — ≥ 50 000 events/s is board row 1c and is settled by row 1d's rig.

ADR: docs/rewrite/decisions/0012-pg-to-lmdb-migration.md (D1–D12).

Why it took twenty static rounds

Each round's finding is in the record and each fix has its own commit. The four worth reading:

  • 1891400 — the manifest rename had no fsync (a power loss could leave the name pointing
    at truncated JSON, which read_manifest refuses, which the nonempty-target rule then refuses
    to migrate over), and RowSum added its four limbs independently while ADR 0012 said modulo
    2^256. Both fixed, with three arithmetic tests proved red before green.
  • 1fc47e5 — the frozen-head lenses' blockers: the target gate never looked at data.mdb
    itself, so a symlinked data file reached mdb_env_open; without_credentials returned its input
    untouched when it had no ://, so postgres:alice:secret@host/db printed the password; the
    store-path trio was two-thirds applied; and an unreachable source still spent the pool's 60 s
    acquire timeout in silence because the eager dial preceded the probe. That last one now
    reports the driver's own error — one direct PgConnection::connect_with bounded by
    min(DB_ACQUIRE_CONNECTION_TIMEOUT, MIGRATION_CONNECT_CAP_MS), so a refused port answers at
    once with Connection refused (os error 111) and a black-holed host is capped at ten seconds
    whatever the pool was told. No new operator surface: the cap is a constant, and the help
    states the bound. Its first version passed for the wrong reason in two different ways — sqlx
    gives up at 15 s on a lazy pool, so the library supplied the timeout the test claimed to prove;
    and, once that was fixed, the budget was derived from the pool's timeout, whose shipped default
    is 60000, so the repository's own .env reproduced the old sixty seconds exactly (measured:
    60 013 ms). The ceiling is proven by inversion — removing the direct attempt makes the same
    test take 60.017 s and fail its clock assertion — because the case it exists for (a host that
    drops packets rather than refusing them) has no test here; test: prove the migration connect cap against a black-holed host #1122 carries one against RFC 5737's
    unrouted 192.0.2.1, with a skip where the environment answers. A schemeless DB_URI is now
    refused before anything is dialled rather than
    redacted: sqlx parses it as a cannot-be-a-base URL, so the opaque path became the database name
    and the driver's error printed the password verbatim. The refusal is deliberately stricter
    than sqlx
    in one direction: postgres:/, postgres:// and postgres://// all parse to
    host=localhost in sqlx and are refused here, because a cutover that guesses which server to
    copy from is worse than one that stops; a test pins that divergence so nobody "fixes" it back.
  • 7c1a571 — three defects the last static rounds found, all in code written to close a
    finding: a DB_URI query string (?password=) printed whole into an error (without_credentials
    now drops the userinfo, the query and the fragment); a disabled or offline read replica
    stopping a copy that reads the master and nothing else (PostgresBackend::master_only_from_env
    reads the master settings and none of the replica ones); and the store directory created at the
    umask's mercy, so umask 002 gave a 0775 directory — wider than heed's 0600 files and refused by
    the target check on the next run. It is created 0700 now, with a test that reads the mode.
    The help also no longer promises a restart the tool refuses: deleting the manifest leaves a
    store with rows and no manifest, which is the one shape the copy declines, so starting over
    takes a fresh --target or removing the manifest and the store together.
  • 4ab040d — a security lens finding: the manifest was created 0644 and its after is a hex
    row key, which for invite_codes is the invite code — a bearer credential the relay's own
    join-request handler protects. It is now created exclusively at mode 0600 with its temp path
    cleared first (a planted symlink there used to be written through), and a copy refuses a
    symlinked, group- or other-writable target and a data.mdb owned by another uid, naming the
    resolved store path.
  • b88bb33 — a data.mdb is a weaker thing than a store: the file test that replaced the
    probe cannot tell a store from a truncated one, so a manifest that counts rows now also
    requires at least one of them to be there. The inversion that found it was raise_report_sequence
    in lmdb/metadata.rs: with the comparison flipped nothing turned red, and a migrated report
    with id 7 left the next create returning 1 — a collision with the migrated row. It has a test
    now that fails left: 1, right: 8 when flipped.
  • 4486729 — open_existing opened the named databases in a read transaction and dropped
    it without RoTxn::commit. heed documents that this makes the handles EINVAL for later
    transactions; in a fresh process --verify would have failed against a real store. The test
    that would have caught it now runs the binary in a fresh process
    (the_binary_verifies_a_store_this_process_wrote).

That class — a test whose harness supplies what production must supply itself — produced three
findings here (a DB_* default dialling a local Postgres, a 60 s acquire timeout standing in
for an unreachable host, and this one). It is recorded as cameri/skills#293, and the spec's
criteria preamble now asks the question explicitly.

Evidence

  • Oracle cargo test -p nostream-cli --all-features --test migrate_cli — 31 passed at the head;
    the copy-and-verify tests run in ~2.5 s, and two of the new ones deliberately wait out a 1 s
    source probe, which is why the whole target reads ~9 s;
    at base b42f833 the same command exits 101 (no test target named migrate_cli).
  • Second oracle cargo test -p nostream-storage --no-default-features --test migration_seam — 1 passed.
  • The five validation commands from CLAUDE.md:30-36, all green: fmt; both clippy passes;
    cargo test --workspace --all-features; cargo build --workspace --all-features --release;
    plus cargo check -p nostream-storage --no-default-features --all-targets.
  • 20 acceptance criteria in the job's spec.md, each with the command and its real output in
    build.md; AC12 (the real-Postgres arm, #[ignore]d and run by CI) and AC13 (the ADR) are
    judged by the approver.
  • Static: twenty rounds, static/round-N.json (greptile) and static/round-N-clippy.json (a
    clippy JSON projection from the build member, which has no greptile key). **Round 20 and this
    head's clippy are the zeros that matter: both providers clean at 8be97bc, the tree unmoving
    during the read. Two commits after it are a CI feature-gate fix (3ce517a: DirBuilderExt and
    i32_from_key gated on the features that use them — the step Storage builds without the
    Postgres driver
    was red and no local command had run that feature set) and a one-sentence doc
    correction; both are verified at the head by the same two steps CI runs, which I ran by hand:
    cargo check -p nostream-storage --no-default-features --all-targets and the strict clippy
    (-D warnings -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic). Earlier zeros are
    history rather than coverage — every round has been a read of a different tree. Fourteen
    findings across the job, four of them in code written to close the one before — the last three
    in the tail: a refusal that was stricter than the URL parser, a comment claiming a discrimination
    its test lacked, and a refusal that echoed a keyword DSN's password.
  • Reviews: five lenses at the frozen head 8d3e4c9 (code, security, ux, architecture,
    performance) returned four CHANGES and one PASS; their blockers were closed and re-read
    against the fix deltas by the same lenses, and the final tail by code. The reports are in the
    job record under round-9/.

Limits, stated rather than implied

Four that a reviewer should weigh first, in order:

  1. The command's resume path has no end-to-end test without a live Postgres. The store check
    and the writable open are driven as a pair in the_resume_check_leaves_the_store_openable, in
    the order run performs them; reaching the open through the binary would require moving the
    source dial ahead of the check, which breaks the guarantee that a run failing on an unparsable
    DB_URI creates nothing.
  2. One column list is still hand-kept. The users source read restates a column list that
    postgres/users.rs owns; hoisting it is follow-up storage: hoist the users column list into postgres/users.rs #1119, because the only tests that can catch
    a hoist going wrong are the #[ignore]d parity target and scripts/storage-parity.sh, and both
    need a live Postgres. The acceptance there is the same row set before and after, not the
    same column count — order is what a hand-kept list drifts on.
  3. The migration command is Unix-only. Its file handling imports std::os::unix ungated;
    nothing in this workspace or its CI builds another target, and gating it — #[cfg(unix)] on
    the command, a non-Unix dispatch stub, gated tests — cannot be compiled on this host, so it
    belongs in follow-up cli: the migration command assumes Unix without a cfg gate #1121 with a windows-latest job as its acceptance rather than in an
    unverified last commit.
  4. A store that is present but foreign is trusted. A resumed run refuses a store when a table
    the manifest counts rows for is empty; a store that holds some of the counted rows is taken
    at its word. The check distinguishes empty from non-empty, not this store from another.

And the properties an operator should know, each deliberate:

  • The source is not fingerprinted: resuming against a different database still verifies against
    its own target. Recording a URL would put credentials in a file (spec Assumption 8).
  • The relay must be stopped. That is documented in the help's first line and not enforced;
    nothing in the repository proves two processes may share one LMDB environment ([INFERENCE], D9).
  • events.id — the Postgres v4 uuid — is the one field not carried; no LMDB reader uses it.
  • The checksum detects corruption and is not collision-resistant.
  • A tag value at LMDB's 469-byte key bound fails the batch and stops the run rather than being
    skipped, so an operator decides (D10).

Deliberate decisions a reviewer should see

  • fe2823d's author and committer email reads phoenix@ricardocabral.go — a typo, in a
    commit that is already published. Neither main nor rewrite/main requires signatures, and
    rewriting a branch reviewers have read costs more than the typo, so it stands. Flagging it here
    so it is read as a decision rather than discovered.
  • The command's resume path is not exercised end to end without a live Postgres: the source
    is dialled before the target is opened (which is what keeps "a run failing on an unparsable
    DB_URI creates nothing"), so the binary cannot reach that order. The regression is a unit
    test driving the resume check and the writable open in run's order, and AC18 states the gap.

Follow-ups this branch filed

Closes #1115 does nothing on this repository — its default branch is main while this targets
rewrite/main — so the issue is closed by hand, with the merge sha, after the merge.

One thing a security reviewer will ask

A keyword/value DB_URI (host=… password=s3cret) still prints its password in a source
error; so does the base commit, so this branch neither introduced nor fixed it. The redaction
covers URIs and now refuses a schemeless one before anything is dialled. If the keyword/value
form is worth supporting, it needs its own redaction and its own test — not a name-matching pass
bolted onto this one.

About the Greptile Review check

It is red on a base review, not on this diff: its own summary reads Base review: Confidence 3/5 —
below your required 5/5 … this check reflects your team's confidence threshold, not a review
failure
. It scores rewrite/main, so it will stay red whatever is pushed here. The evidence for
this branch is round 20 (greptile and clippy both clean at 8be97bc) and the diff-level reviews:
five lenses at a frozen head, then a confirmation read of every fix delta.

chappie-daemon and others added 30 commits November 12, 2026 00:12
Two static findings, both from round 4.

`write_manifest` renamed its temporary file without syncing the contents or
the directory, so a power loss could leave `migration-manifest.json` as a name
pointing at truncated JSON. `read_manifest` refuses that, and the refusal of a
target that holds rows without a manifest then blocks the store it describes -
the operator's only way forward is a discard. The file is now synced before
the rename and the directory after it, and ADR 0012 D5 says so instead of
claiming only "a temporary file and a rename".

`RowSum::add` added its four words independently, dropping the carry between
them, while the ADR documents the checksum as the sum modulo 2^256. Copy and
verify agreed with each other so nothing was wrong in practice, but a checker
written from the document computed a different number. The carry now crosses
the word boundary - the loop runs from index 3 to 0 because limb 0 is the most
significant - and three unit tests pin it: a wrap carrying upward, a full wrap
to zero, and the ordering. The first two fail against the independent add.

`a_failed_run_reports_migration_failed_and_exits_1` also took 60 s: with
nothing on port 1 the connection is refused at once, but the pool still
retried to the acquire timeout. `DB_ACQUIRE_CONNECTION_TIMEOUT=1000` takes the
suite from 60.04 s to 2.29 s.
`open_existing` opened the nine databases inside a read transaction and
dropped it. heed publishes a database handle only once its opening
transaction has committed (`src/txn.rs`, quoted by its own documentation for
exactly this case), so the handles stayed unusable and the next transaction on
them answered `Invalid argument`. In practice that is every `--verify` and
every automatic verification of a completed migration, because both open the
store through this path in a fresh process.

No test caught it because every in-process test opens the store writable
first, where heed's process-global registry already holds valid handles. The
new test closes that: it fills a store in the test process and then runs the
binary, which has never opened it, and requires `Verified 9 tables, N rows`.
Without the commit it fails with `Migration failed: dvm_jobs: lmdb operation
failed: Invalid argument (os error 22)`; with it, 20 passed.

ADR 0012 now states the requirement in the read-only opener's contract rather
than leaving it to be rediscovered.
The resume check opened the store read-only to decide whether it was there.
heed keeps its environment registered for the life of the process, so the
writable open that follows answered `BadOpenOptions` - the probe refusing a
resume exactly when the store it wanted existed. It is the store's file that
is asked about now, which is what `data.mdb` means and what verify already
used, so nothing is opened and nothing is registered.

Nothing in the suite could see it: the one resume test that runs the command
fails on the source before the store is opened. `the_resume_check_leaves_the
_store_openable` drives the check and the writable open in one process, in
the order the run performs them; with the probe in place it fails
`an environment is already opened with different options`, and with the file
check it passes.

Three more from the same round:

- the documented checksum now has a golden value, computed outside the crate
  from one row's bytes on D4's framing (`len32 | key | canonical`, summed
  modulo 2^256). Removing the length prefix from `add` turns it red; the unit
  tests only ever pinned the carry.
- "verify creates nothing" was false: a read-only LMDB open recreates
  `lock.mdb` beside `data.mdb` (measured), and refuses a directory it cannot
  write it in. The comment and ADR 0012 now say what it does.
- the nine-name refusal had no fixture with the right count and a wrong name,
  so a count check would have passed it. One is added.

`open_existing` also stops carrying its own `#[allow(unsafe_code)]`: both
openers go through `LmdbEnv::open_with`, so the crate keeps the single
audited boundary its CLAUDE.md names. Its doc comment repeated the false
premise about dropping the handle freeing the path, as did the test comment
that dropped one for that reason.

`fe2823d`'s `phoenix@ricardocabral.go` stays as it is: it is published, no
ruleset requires signatures, and rewriting a branch a lens has read costs
more than the typo.
Asking about the store by its file tells a missing store from a present one,
not a store this run wrote from one truncated or written by something else
that still has a `data.mdb`: the copy would add the remaining rows to a table
that never held the ones the manifest counts and report success over a store
missing them. A table the manifest counts rows for must now hold at least one,
which is one read per counted table. Verify would have caught it, but only if
someone ran it.

Two documents still described the probe round 6 removed: ADR 0012's D5 said
the run "opens that store read-only first", and `backend.rs`'s `NO_LOCK` bullet
claimed no env flags are passed and the crate never touches the lock file.
`open_existing` passes `READ_ONLY`, and a read-only open recreates `lock.mdb`.
The guarantee is unchanged; its evidence is now accurate.

The six criterion-bearing branches the code lens could not see fail now can:
each was inverted and each turned tests red - the complete-manifest branches in
`run` and `migrate_with` (2 and 12 failures), the occupancy guard (5), the
manifest write (1), the skip of finished tables (11). The sixth, the report
sequence in `lmdb/metadata.rs`, turned nothing red, which was a real gap: a
migrated report with id 7 left the next `create` returning 1. It now has a test
that fails `left: 1, right: 8` when the comparison is inverted.
The manifest was created 0644 under a 022 umask and records the last key written per table.
For `invite_codes` that key is the invite code itself - a bearer credential NIP-43 accepts -
and the store's own files are heed's 0600, so the manifest was the world-readable copy of a
secret it was the only copy of. It is now created exclusively at 0600, which closes a second
hole with the same flag: a planted symlink at `migration-manifest.json.tmp` used to be written
through and renamed into place. Anything at the temporary path is removed first and the file is
then created with `create_new`, so a second plant fails instead of being followed.

Nothing inspected the target either. A group- or world-writable target lets another local user
pre-place `<target>/lmdb/data.mdb` and read the whole dataset out of a file they own; a symlinked
target sends the copy somewhere the operator did not name. Both are refused before anything is
opened, and the summary now prints the store the copy landed in.

Each of the three is proved red by the fix's absence: the manifest test fails on mode 0644 and on
the followed link, and both target tests fail on their message when the refusal is removed.

`.gitignore` gains root-anchored `/lmdb/` and `/migration-manifest.json`: `--target .` writes the
dataset into a public repository's working tree.

ADR 0012 also stops saying heed opens with no environment flags - the verify opener passes
`READ_ONLY` - and records the manifest's mode and the target refusal.
The run named none of the three things an operator needs when it goes wrong.

The source: `PostgresBackend::from_env` falls back to `postgres@localhost:5432/postgres` when
nothing is set, so an unconfigured run dialled whatever a local server held, and with nothing
listening it waited out the pool's 60 s acquire timeout (measured: exactly 60.00 s, "pool timed
out while waiting for an open connection"). The setting is now read and named first - `DB_URI`,
else `DB_HOST`/`DB_NAME`, with the password dropped - and one row is read before the copy so an
unreachable host is reported as the database it could not reach.

The store: the summary's `Store:` line is canonicalised, so a target reached through a link shows
the directory the copy actually landed in, and a `--target` that is a file is named as one
instead of failing on `Not a directory (os error 20)`.

The way back: the help says a re-run resumes and that deleting the manifest starts a new copy,
and the refusal of a store the manifest counts rows for repeats it.

`micros` and the `i32` key flip now live in one feature-free module, `crate::keys`, with
`lmdb::encoding` re-exporting them and `postgres::migration` using the same inverse. They were
restated three times, each with a comment explaining it had to compile without the other driver;
a divergence there is a false checksum mismatch after a full copy has already run.

Each guard is proved by its absence: without the source naming the test waits 60 s and fails on
the pool message; without the canonicalise the link test reports the argument; without the
password drop the credential is printed; without the help text the test has nothing to find.
`without_password` removed the userinfo's password and left everything else, so a URI that
carries the credential as a query parameter - `?password=secret` - was printed whole into the
error that reaches stderr and any captured log. The query string is now dropped as well, not
matched against credential-shaped keys: a percent-encoded key ends a name-based check, and the
description exists to identify the source, which the host and database already do.

Proved by absence: with the query left in, the test prints
`postgres://127.0.0.1:1/nostream?password=secret&sslmode=require` verbatim.
The description kept the user name after the password went. AC19 asks for the host and
database, a role is neither, and an identifier carried into a message that does not need it is a
smaller copy of the problem the redaction was written to fix. The userinfo, the query string
and the fragment all go; a permission error still names the role, because the server's own
message says it.
Three findings, none of them from a test that existed before it.

A replica that is offline must not stop a cutover. `from_env` dials the replica when
`READ_REPLICA_ENABLED=true` and fails when it cannot reach it, which is right for the relay and
wrong for a one-shot copy that reads the master and nothing else. `master_only_from_env` reads
the master settings and none of the replica ones; the test proves it by pairing a zero replica
count - which makes `from_env` refuse before it dials - with the master-only opener, which then
fails on the connection instead.

The help promised a restart that the tool refuses: "delete `migration-manifest.json` to start a
new copy" leaves a store that holds rows and no manifest, which is exactly the shape `migrate_with`
declines to copy over. It now says a fresh `--target` is what starting over takes, or both gone
together.

The store directory is created 0700 rather than at the umask's mercy. On a host with umask 002
`create_dir_all` leaves the dataset group-writable, which is a wider audience than heed's 0600
files - and is refused by the migration's own target check on the next run, so the tool would
decline its own store.

All three are proved by absence: the master-only test fails with `READ_REPLICAS must be at
least 1` when the opener reads the replica settings, the help test has nothing to find when the
sentence is the old one, and the directory test reads 0755 when the builder takes the umask.
The target check read the directory's type and permission bits and stopped there. An owner can
replace a directory between that check and the open whatever its bits say, so a copy running as
root over a predictable path in a shared directory follows the replacement into files the other
user can read - which is the redirect the mode check was meant to close.

The owner is compared with the process's effective uid. `std::fs` exposes a file's owner but
not the caller's, and this crate runs on Linux, so the uid comes from `/proc/self/status` rather
than a new dependency for one integer. When `/proc` cannot say, the mode check is what stands.

Proved by absence: the refusal is measured against uid 0 on a directory this process owns, and
removing the branch makes that test fail. It cannot be measured end to end here because making a
directory belong to another user needs root.
The ownership test passed `Some(0)` and expected a refusal, which holds only when the test
process is not root. A suite running as root creates a root-owned tempdir, the check correctly
allows it, and `expect_err` fails - so the test would have broken `cargo test --workspace
--all-features` in a root-run container while proving nothing about the code.

It now reads the directory's own owner and passes a different uid, which is the property the
refusal is about.
… or fails fast

Five contract misses from the frozen-head lenses.

The target gate inspected `<target>` and `<target>/lmdb` and never `data.mdb`, which `is_file()`
and LMDB both follow: a link there sent `--verify` through it into `mdb_env_open` and heed wrote
`lock.mdb` beside whatever it resolved to. The gate now reads the file's own metadata and refuses
a symlink (proved by a unit test that plants one), a non-regular file, and a foreign owner.

`without_credentials` returned any string without `://` untouched, so
`postgres:alice:secret@127.0.0.1:1/nostream` - the shape libpq accepts - printed the password.
The query, the fragment and everything up to the last `@` now go before the scheme test.

The source pool dialled eagerly, so an unreachable host spent the pool's sixty-second acquire
timeout before the probe could say anything (measured: 60.003 s, red proof). It is built lazy
and the probe bounds the wait with `MIGRATION_CONNECT_BUDGET_MS` (10 s); at the shipped setting
the same source now fails in 10.00 s, and the test asserts the clock rather than shortening the
setting.

The store path has one home: `open_existing` and `data_dir` both call `store_dir`, and
`data_dir` regained the doc comment the insertion had taken.

And the false remedy is gone from all three places it survived - the help (round 10), the
refusal string and the test that pinned it, and ADR 0012. Deleting the manifest does not restart
a half-filled store; a fresh `--target` does, or the manifest and the store removed together.
…ore_dir

`1fc47e5`'s message said both openers call `store_dir`; only the writable one did. That made
`store_dir`'s "one home for the shape" false and left the branch record asserting a fix the tree
lacked, so the correction is to make the claim true rather than to rewrite a published message.
`open_existing` now calls `store_dir` too, and three docs stop saying things the code does not:

- `master_only_from_env` no longer reads as a constructor that dials. It builds a lazy pool, so
  `Ok` means the settings parse, not that the server answers; the copy's probe is what waits.
- `MIGRATION_CONNECT_BUDGET_MS` is the probe's *default*, not the longest a migration waits:
  `DB_ACQUIRE_CONNECTION_TIMEOUT` overrides it, and later batches use the pool's own acquire
  timeout.
- `store_dir`'s paragraph is left as written, because the two openers now make it true.
…t a host

Two confirmations, both right, both about the same habit: the code trusted something the
shipped configuration does not provide.

The probe read the source through the pool, so sqlx retried a refused socket until the pool's
acquire timeout. With `DB_ACQUIRE_CONNECTION_TIMEOUT=60000` - the value `deploy/env.example`,
`SETUP.md` and `CONFIGURATION.md` all ship - that was 60 013 ms and the pool's sentence, byte
for byte what the branch printed before the first AC19 fix. And even the fast path never printed
the reason: the pool's message was always what surfaced. It is one direct connection now, bounded
by `MIGRATION_CONNECT_CAP_MS` (10 s) whatever the pool is told, so a refused port answers at once
with `error communicating with database: Connection refused (os error 111)` and a black-holed
host is capped rather than silent. Red proof: without the direct attempt the same test takes
60.017 s and the clock assertion fails.

A schemeless `DB_URI` is now refused before anything is dialled. sqlx parses one as a URL with no
base: the opaque path becomes the database name and the host falls back to localhost, so the copy
dials the wrong server and a failure echoes the string back - with the password in it - as
`database "alice:secret@host:5432/db" does not exist`. Redacting the message was never enough.
The test that asserted the redaction is now a refusal test, and it fails without the check.

The constant is named for what it is: a cap, not a budget, because the operator's setting lowers
it rather than the other way round.
The refusal for a hostless `DB_URI` was too strict and too loud.

Too strict: it read the authority alone, so `postgres:///?host=/var/run/postgresql&dbname=nostream`
and `postgres:///?hostaddr=127.0.0.1` were refused - both shapes sqlx dials, and the only ones a
Postgres behind a Unix socket offers. A relay backed by a socket could no longer be migrated at
all, which is a regression introduced by the fix for a leak. The rule now refuses a string with no
`://` at all, and accepts an empty authority when the query names `host` or `hostaddr`. Red proof:
with the authority-only rule the two new tests fail and the message reads
`not a URL with a host (postgres:///)`.

Too loud: the refusal echoed the value after `without_credentials`, which strips userinfo and a
query - and a libpq keyword string has neither, so `DB_URI="host=… password=s3cret"` printed the
password. The refusal now names the shape and neither shape's value. Red proof: with the echo
restored the new test finds `s3cret` in stderr.

The clock test's comment is corrected to what it proves. A refused port answers in milliseconds
whatever the bound is, so inverting `.min(cap)` leaves it green; the message and the fast path are
what it tests, and `#1122` carries the cap's duration on a host that drops packets.
Measured against sqlx's own parser: `postgres:/`, `postgres://` and `postgres:////` all parse to
`host=localhost`, and the empty string does not parse at all. The check refuses the first three,
which is stricter than sqlx by design - a cutover that guesses which server to copy from is worse
than one that stops. The test records the divergence and the reason, so nobody later "fixes" it
back to sqlx's behaviour.

Red proof: with the shape check made permissive, `postgres://` is dialled and the test fails with
`could not read the source at postgres:// … Connection refused` - it went looking for a server it
was never told about.
@changeset-bot

changeset-bot Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6885fcf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 34c4a502-39b2-40df-b8b3-6ec42b071583

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@greptile-apps

greptile-apps Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5

[Medium impact] The changes affect an operator-run migration workflow, not the relay's normal runtime.

The PR still needs protection against another user replacing a target through its parent directory before it is safe to merge.

Findings

  1. P1 Security Another user redirects the copy ▶

Summary

Adds nostream-cli migrate pg-to-lmdb to copy nine Postgres tables into LMDB.

  • Operators can copy nine Postgres tables into LMDB and verify the result.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Check target and manifest] --> B{Verify or already complete?}
  B -->|Yes| C[Open existing LMDB store read-only]
  C --> D[Compare row counts and checksums]
  B -->|No| E[Check resume store and connect to Postgres]
  E --> F[Read next table batch]
  F --> G[Commit rows to LMDB]
  G --> H[Save manifest]
  H --> I{More rows or tables?}
  I -->|Yes| F
  I -->|No| J[Report rows and throughput]
Loading

Reviews (4) · Last reviewed commit: "docs(migrate): name the schemes the gate..." · Reviewed by Greptile

Comment on lines +694 to +695
let mode = metadata.permissions().mode();
if mode & 0o022 != 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Another user redirects the copy

refuse_unsafe_target checks write permissions but not who owns the directory. If an operator runs the migration as root against a predictable path in a shared directory, another user can create that target with mode 0755. It passes this check, but its owner can replace lmdb before the store opens and redirect the copy into files they can read.

Require existing target directories to belong to the running user, and ensure their parent cannot be swapped before opening the store.

How this was verified: The target check reads only directory type and permission bits, then the copy later opens the same paths without checking their owner.

…efuses

`#1126`'s first three items, closed where the guard lives.

A `?` inside a `#` fragment was taken for a query, so `postgres://#?host=/sock` passed the gate and
dialled localhost - the exact guess the gate exists to stop. The fragment is dropped before the
query is read. Red proof: at the committed rule the new test finds
`could not read the source at postgres:// … Connection refused`, i.e. it went to localhost.

A string containing `://` outside a Postgres scheme passed and was echoed whole:
`password=ab://cd` reached sqlx, which read `password=ab` as the scheme and `cd` as the host, and
the failure printed the string verbatim. Only `postgres` and `postgresql` are a URL to this gate,
in either case, so a libpq keyword string with an accidental `://` is refused before anything is
dialled. Red proof: the same input printed `could not read the source at password=ab://cd`.

`user@:5432` carries a port and no host; sqlx reports `empty host`, and the gate now agrees rather
than counting the port as an authority. An IPv6 literal stays a host through its brackets.

The doc comment now states what the predicate does, including the three shapes refused more
strictly than sqlx (`postgres://`, `postgres:////`, `postgres:///db` - all `localhost` to sqlx)
and the reason: a cutover that guesses its server is worse than one that stops.
@coveralls

coveralls commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 43189397381

Coverage remained the same at 77.588%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 12081
Covered Lines: 9792
Line Coverage: 81.05%
Relevant Branches: 6342
Covered Branches: 4502
Branch Coverage: 70.99%
Branches in Coverage %: Yes
Coverage Strength: 759.24 hits per line

💛 - Coveralls

`cargo check -p nostream-storage --no-default-features --all-targets` failed at `8be97bc`: the
`DirBuilderExt` import is only used by the lmdb environment's 0700 directory, and `i32_from_key` is
only called from `lmdb::encoding` and `postgres::migration`. With neither feature on, one is
unused and the other dead, and `-D warnings` turns both into errors.

Both are gated on the features that use them. Both arrived on this branch (`7c1a571` and
`e52d8ed`), so this is the branch's own break, found by CI on the PR rather than by any of the
twenty static rounds - a feature combination none of the local commands covered. The local
verification list now includes CI's own: no features, and clippy with `--all-features`.
The sentence closing the previous doc defect wrote `postgres://` with two opposite meanings: the
paragraph above refuses it, and the list of what is accepted meant the scheme. It now names
`postgres` and `postgresql`, so the two sentences cannot disagree.

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

factory Built by the software factory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants